feat: Implement initial Caddy panel UI with new pages, components, and styling, along with updated dependencies.

This commit is contained in:
BLACK
2025-12-08 22:47:42 +01:00
parent ea8e3a5f7c
commit 774432b720
38 changed files with 3124 additions and 250 deletions

View File

@@ -1,36 +1,37 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# CaddyPanel
## Getting Started
Ein modernes Web-Interface für den Caddy Web Server.
First, run the development server:
Dieses Projekt basiert auf [Next.js](https://nextjs.org) und bietet eine intuitive Oberfläche zur Verwaltung von Caddy.
## Erste Schritte
Starten Sie den Entwicklungsserver:
```bash
npm run dev
# or
# oder
yarn dev
# or
# oder
pnpm dev
# or
# oder
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Öffnen Sie [http://localhost:3000](http://localhost:3000) in Ihrem Browser.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Funktionen
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
- **Dashboard**: Überblick über Systemstatus und Aktivitäten.
- **Proxy Regeln**: Visueller Drag-and-Drop Editor für Reverse-Proxy-Routen.
- **Domain Verwaltung**: Einfaches Hinzufügen und Konfigurieren von Domains inkl. SSL.
- **Log Viewer**: Detaillierte Log-Analyse mit Filtern für Logger, Inhalt und Zeit.
- **API Explorer**: Integriertes Tool zum Testen und Verwalten von API-Endpoints.
- **Zertifikate**: Übersicht über aktive SSL-Zertifikate.
## Learn More
## Technologien
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
- Next.js 14
- React
- TypeScript
- CSS Modules

99
package-lock.json generated
View File

@@ -8,9 +8,16 @@
"name": "caddy-panel",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@types/uuid": "^10.0.0",
"clsx": "^2.1.1",
"lucide-react": "^0.556.0",
"next": "16.0.7",
"react": "19.2.0",
"react-dom": "19.2.0"
"react-dom": "19.2.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/node": "^20",
@@ -261,6 +268,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
@@ -1289,6 +1349,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
@@ -2292,6 +2358,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4264,6 +4339,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.556.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.556.0.tgz",
"integrity": "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5746,6 +5830,19 @@
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -9,9 +9,16 @@
"lint": "eslint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@types/uuid": "^10.0.0",
"clsx": "^2.1.1",
"lucide-react": "^0.556.0",
"next": "16.0.7",
"react": "19.2.0",
"react-dom": "19.2.0"
"react-dom": "19.2.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/node": "^20",

165
src/app/api/page.tsx Normal file
View File

@@ -0,0 +1,165 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { DataTable } from "@/components/Common/DataTable";
import { Plus, Play, Edit2, Trash2, Globe, FileText } from "lucide-react";
import { ApiEndpointModal, ApiEndpoint } from "@/components/Api/ApiEndpointModal";
import { ApiTestModal } from "@/components/Api/ApiTestModal";
const MOCK_ENDPOINTS: ApiEndpoint[] = [
{ id: "1", method: "GET", path: "/api/users", description: "List all users", headers: "{}", body: "" },
{ id: "2", method: "POST", path: "/api/users", description: "Create a new user", headers: "{}", body: '{"name": "New User"}' },
{ id: "3", method: "GET", path: "/api/health", description: "System health check", headers: "{}", body: "" },
];
export default function ApiPage() {
const [endpoints, setEndpoints] = useState<ApiEndpoint[]>(MOCK_ENDPOINTS);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isTestModalOpen, setIsTestModalOpen] = useState(false);
const [currentEndpoint, setCurrentEndpoint] = useState<ApiEndpoint | null>(null);
const handleAdd = () => {
setCurrentEndpoint(null);
setIsEditModalOpen(true);
};
const handleEdit = (endpoint: ApiEndpoint) => {
setCurrentEndpoint(endpoint);
setIsEditModalOpen(true);
};
const handleDelete = (id: string) => {
setEndpoints(endpoints.filter(e => e.id !== id));
};
const handleRun = (endpoint: ApiEndpoint) => {
setCurrentEndpoint(endpoint);
setIsTestModalOpen(true);
};
const handleSave = (data: ApiEndpoint) => {
if (currentEndpoint && !isTestModalOpen) { // Edit mode
setEndpoints(endpoints.map(e => e.id === currentEndpoint.id ? data : e));
} else {
setEndpoints([...endpoints, data]);
}
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-lg)' }}>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>API Explorer</h1>
<p style={{ color: 'var(--text-muted)' }}>Definieren und testen Sie Ihre eigenen API-Endpoints</p>
</div>
<button
onClick={handleAdd}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
backgroundColor: 'var(--primary)',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
<Plus size={16} />
Endpoint hinzufügen
</button>
</div>
<DataTable
data={endpoints}
keyField="id"
columns={[
{
header: "Methode",
accessor: (item) => (
<span style={{
fontWeight: 600,
fontSize: '0.75rem',
padding: '2px 6px',
borderRadius: '4px',
backgroundColor: 'var(--bg-surface)',
border: '1px solid var(--border-color)',
color: item.method === 'GET' ? 'var(--primary)' : item.method === 'POST' ? 'var(--status-success)' : 'var(--text-main)'
}}>
{item.method}
</span>
)
},
{ header: "Pfad", accessor: "path", className: "font-mono text-sm" },
{ header: "Beschreibung", accessor: "description" },
{
header: "Aktionen",
accessor: (item) => (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
onClick={() => handleRun(item)}
title="Endpoint testen"
style={{
padding: '4px 8px',
background: 'var(--primary)',
border: 'none',
color: 'white',
cursor: 'pointer',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '0.75rem',
marginRight: '8px'
}}
>
<Play size={12} /> Test
</button>
<div style={{ display: 'flex', gap: '4px', marginRight: '8px', borderRight: '1px solid var(--border-color)', paddingRight: '8px' }}>
<Link href={`/proxy?filter=${encodeURIComponent(item.path)}`} title="Proxy Regel bearbeiten" style={{ color: 'var(--text-muted)', padding: '4px' }}>
<Globe size={16} />
</Link>
<Link href={`/logs?filter=${encodeURIComponent(item.path)}`} title="Logs ansehen" style={{ color: 'var(--text-muted)', padding: '4px' }}>
<FileText size={16} />
</Link>
</div>
<button
onClick={() => handleEdit(item)}
title="Edit"
style={{ padding: '4px', background: 'transparent', border: 'none', color: 'var(--text-main)', cursor: 'pointer' }}
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDelete(item.id)}
title="Delete"
style={{ padding: '4px', background: 'transparent', border: 'none', color: 'var(--status-error)', cursor: 'pointer' }}
>
<Trash2 size={16} />
</button>
</div>
)
}
]}
/>
<ApiEndpointModal
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
onSave={handleSave}
initialData={currentEndpoint}
/>
<ApiTestModal
isOpen={isTestModalOpen}
onClose={() => setIsTestModalOpen(false)}
endpoint={currentEndpoint}
/>
</div>
);
}

58
src/app/certs/page.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { DataTable, StatusBadge } from "@/components/Common/DataTable";
interface Certificate {
id: string;
domain: string;
issuer: string;
expiryDate: string;
status: "valid" | "expiring" | "expired";
}
const MOCK_CERTS: Certificate[] = [
{ id: "1", domain: "example.com", issuer: "Let's Encrypt", expiryDate: "2024-01-15", status: "valid" },
{ id: "2", domain: "api.test.org", issuer: "Let's Encrypt", expiryDate: "2024-01-20", status: "valid" },
{ id: "3", domain: "blog.mysite.net", issuer: "ZeroSSL", expiryDate: "2023-11-01", status: "expiring" },
{ id: "4", domain: "old.legacy.net", issuer: "Let's Encrypt", expiryDate: "2023-09-10", status: "expired" },
];
export default function CertificatesPage() {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-lg)' }}>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Zertifikate</h1>
<p style={{ color: 'var(--text-muted)' }}>Verwaltete TLS Zertifikate</p>
</div>
</div>
<DataTable
data={MOCK_CERTS}
keyField="id"
columns={[
{ header: "Domain", accessor: "domain" },
{ header: "Aussteller", accessor: "issuer" },
{
header: "Ablaufdatum",
accessor: (item) => (
<span style={{
color: item.status === 'expiring' ? 'var(--status-warning)' :
item.status === 'expired' ? 'var(--status-error)' : 'var(--text-main)'
}}>
{item.expiryDate}
</span>
)
},
{
header: "Status",
accessor: (item) => (
<StatusBadge
status={item.status === 'valid' ? 'success' : item.status === 'expiring' ? 'warning' : 'error'}
label={item.status.toUpperCase()}
/>
)
},
]}
/>
</div>
);
}

154
src/app/domains/page.tsx Normal file
View File

@@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { DataTable, StatusBadge } from "@/components/Common/DataTable";
import { Plus, Edit2, Trash2, Globe, FileText, ShieldCheck } from "lucide-react";
import { DomainModal, DomainData } from "@/components/Domains/DomainModal";
const MOCK_DOMAINS: DomainData[] = [
{ id: "1", domain: "example.com", port: "443", status: "active", ssl: true },
{ id: "2", domain: "api.test.org", port: "443", status: "active", ssl: true },
{ id: "3", domain: "dev.local", port: "80", status: "inactive", ssl: false },
{ id: "4", domain: "blog.mysite.net", port: "443", status: "error", ssl: true },
];
export default function DomainsPage() {
const [domains, setDomains] = useState<DomainData[]>(MOCK_DOMAINS);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingDomain, setEditingDomain] = useState<DomainData | null>(null);
const handleAdd = () => {
setEditingDomain(null);
setIsModalOpen(true);
};
const handleEdit = (domain: DomainData) => {
setEditingDomain(domain);
setIsModalOpen(true);
};
const handleDelete = (id: string) => {
// Confirm dialogs can be tricky in some environments, removing for now to ensure functionality
// In a real app, we would use a custom modal or toast
setDomains(domains.filter(d => d.id !== id));
};
const handleSave = (data: DomainData) => {
if (editingDomain) {
setDomains(domains.map(d => d.id === editingDomain.id ? data : d));
} else {
setDomains([...domains, data]);
}
setIsModalOpen(false);
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-lg)' }}>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Domains</h1>
<p style={{ color: 'var(--text-muted)' }}>Verwalten Sie Ihre konfigurierten Domains und SSL-Zertifikate</p>
</div>
<button
onClick={handleAdd}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
backgroundColor: 'var(--primary)',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
<Plus size={16} />
Domain hinzufügen
</button>
</div>
<DataTable
data={domains}
keyField="id"
columns={[
{ header: "Domain", accessor: "domain" },
{ header: "Port", accessor: "port" },
{
header: "SSL Status",
accessor: (item) => (
<span style={{
color: item.ssl ? 'var(--status-success)' : 'var(--text-muted)',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '0.875rem'
}}>
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'currentColor'
}}></span>
{item.ssl ? 'Gesichert' : 'Unsicher'}
</span>
)
},
{
header: "Status",
accessor: (item) => (
<StatusBadge
status={item.status === 'active' ? 'success' : item.status === 'error' ? 'error' : 'neutral'}
label={item.status === 'active' ? 'AKTIV' : item.status === 'error' ? 'FEHLER' : 'INAKTIV'}
/>
)
},
{
header: "Aktionen",
accessor: (item) => (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '4px', marginRight: '8px', borderRight: '1px solid var(--border-color)', paddingRight: '8px' }}>
<Link href={`/proxy?filter=${encodeURIComponent(item.domain)}`} title="Proxy Regeln ansehen" style={{ color: 'var(--text-muted)', padding: '4px' }}>
<Globe size={16} />
</Link>
<Link href={`/api?filter=${encodeURIComponent(item.domain)}`} title="API Routen ansehen" style={{ color: 'var(--text-muted)', padding: '4px' }}>
<FileText size={16} />
</Link>
<Link href={`/logs?filter=${encodeURIComponent(item.domain)}`} title="Logs ansehen" style={{ color: 'var(--text-muted)', padding: '4px' }}>
<FileText size={16} />
</Link>
<Link href={`/certs?filter=${encodeURIComponent(item.domain)}`} title="Zertifikate ansehen" style={{ color: 'var(--text-muted)', padding: '4px' }}>
<ShieldCheck size={16} />
</Link>
</div>
<button
onClick={() => handleEdit(item)}
title="Edit"
style={{ padding: '4px', background: 'transparent', border: 'none', color: 'var(--text-main)', cursor: 'pointer' }}
>
<Edit2 size={18} />
</button>
<button
onClick={() => handleDelete(item.id)}
title="Delete"
style={{ padding: '4px', background: 'transparent', border: 'none', color: 'var(--status-error)', cursor: 'pointer' }}
>
<Trash2 size={18} />
</button>
</div>
)
}
]}
/>
<DomainModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSave}
initialData={editingDomain}
/>
</div>
);
}

View File

@@ -1,27 +1,39 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
/* Colors */
--bg-app: #0f1014;
/* Very dark blue-ish grey */
--bg-surface: #181b21;
--bg-surface-hover: #232730;
--bg-surface-active: #2d323b;
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
--color-primary: #1F88C0;
/* Caddy "Curious Blue" */
--color-primary-dim: #16608a;
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
/* Alias for components expecting --primary */
--primary: var(--color-primary);
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--text-main: #ffffff;
--text-secondary: #cbd5e1;
--text-muted: #94a3b8;
--status-success: #22c55e;
--status-warning: #eab308;
--status-error: #ef4444;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* Borders */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--border-color: #2d323b;
--border-light: #3e4552;
}
* {
@@ -30,13 +42,29 @@ body {
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
height: 100%;
}
body {
color: var(--text-main);
background: var(--bg-app);
font-family: var(--font-inter), sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
button {
cursor: pointer;
border: none;
background: none;
font-family: inherit;
}

View File

@@ -1,20 +1,14 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter, JetBrains_Mono } from "next/font/google";
import { AppLayout } from "@/components/Layout/AppLayout";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "CaddyPanel",
description: "Modern web interface for Caddy Reverse Proxy",
};
export default function RootLayout({
@@ -24,8 +18,10 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
<body className={`${inter.variable} ${jetbrainsMono.variable}`}>
<AppLayout>
{children}
</AppLayout>
</body>
</html>
);

146
src/app/logs/page.tsx Normal file
View File

@@ -0,0 +1,146 @@
"use client";
import { DataTable, StatusBadge } from "@/components/Common/DataTable";
import { useState, useEffect } from "react";
import { useSearchParams } from "next/navigation";
interface LogEntry {
id: string;
timestamp: string;
level: "info" | "warn" | "error";
logger: string;
message: string;
}
const MOCK_LOGS: LogEntry[] = [
{ id: "1", timestamp: "2023-10-27 10:42:01", level: "info", logger: "http.log.access.log0", message: "handled request" },
{ id: "2", timestamp: "2023-10-27 10:42:05", level: "info", logger: "http.log.access.log0", message: "handled request" },
{ id: "3", timestamp: "2023-10-27 10:45:12", level: "warn", logger: "http.handlers.reverse_proxy", message: "upstream unstructured" },
{ id: "4", timestamp: "2023-10-27 10:46:00", level: "error", logger: "http.handlers.reverse_proxy", message: "dial tcp 127.0.0.1:8080: connect: connection refused" },
{ id: "5", timestamp: "2023-10-27 10:46:01", level: "info", logger: "http.log.access.log0", message: "handled request" },
{ id: "6", timestamp: "2023-10-27 10:47:33", level: "info", logger: "admin.api", message: "config reload" },
{ id: "7", timestamp: "2023-10-27 10:48:15", level: "error", logger: "tls.obtain", message: "failed to obtain certificate: acme: error" },
];
export default function LogsPage() {
const searchParams = useSearchParams();
const initialFilter = searchParams.get('filter') || '';
// Filters
const [levelFilter, setLevelFilter] = useState("all");
const [loggerFilter, setLoggerFilter] = useState("");
const [contentFilter, setContentFilter] = useState("");
const [timeStart, setTimeStart] = useState("");
const [timeEnd, setTimeEnd] = useState("");
// Apply initial filter if present
useEffect(() => {
if (initialFilter) {
setContentFilter(initialFilter);
}
}, []); // Run only once
const filteredLogs = MOCK_LOGS.filter(log => {
// Level Filter
if (levelFilter !== "all" && log.level !== levelFilter) return false;
// Logger Filter (Global or Specific)
const logger = loggerFilter.trim();
if (logger && !log.logger.toLowerCase().includes(logger.toLowerCase())) return false;
// Content Filter
const content = contentFilter.trim();
if (content && !log.message.toLowerCase().includes(content.toLowerCase()) && !log.logger.toLowerCase().includes(content.toLowerCase())) return false;
// Time Filter
if (timeStart && log.timestamp < timeStart) return false;
if (timeEnd && log.timestamp > timeEnd) return false;
return true;
});
return (
<div>
<div style={{ marginBottom: 'var(--spacing-lg)' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Logs</h1>
<p style={{ color: 'var(--text-muted)' }}>System- und Zugriffs-Log Viewer</p>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '12px',
marginBottom: 'var(--spacing-lg)',
padding: '16px',
backgroundColor: 'var(--bg-surface)',
borderRadius: 'var(--radius-lg)',
border: '1px solid var(--border-color)'
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)' }}>LEVEL</label>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
style={{ padding: '8px', borderRadius: '6px', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-main)', color: 'var(--text-main)' }}
>
<option value="all">Alle Level</option>
<option value="info">Info</option>
<option value="warn">Warnung</option>
<option value="error">Fehler</option>
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)' }}>LOGGER</label>
<input
type="text"
placeholder="z.B. http.handlers"
value={loggerFilter}
onChange={(e) => setLoggerFilter(e.target.value)}
style={{ padding: '8px', borderRadius: '6px', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-main)', color: 'var(--text-main)' }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)' }}>INHALT</label>
<input
type="text"
placeholder="Nachricht suchen..."
value={contentFilter}
onChange={(e) => setContentFilter(e.target.value)}
style={{ padding: '8px', borderRadius: '6px', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-main)', color: 'var(--text-main)' }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)' }}>VON</label>
<input
type="datetime-local"
value={timeStart}
onChange={(e) => setTimeStart(e.target.value)}
style={{ padding: '7px', borderRadius: '6px', border: '1px solid var(--border-color)', backgroundColor: 'var(--bg-main)', color: 'var(--text-main)', fontSize: '0.8rem' }}
/>
</div>
</div>
<DataTable
data={filteredLogs}
keyField="id"
columns={[
{ header: "Zeitstempel", accessor: "timestamp", className: "whitespace-nowrap font-mono text-sm" },
{
header: "Level",
accessor: (item) => (
<StatusBadge
status={item.level === 'info' ? 'neutral' : item.level === 'warn' ? 'warning' : 'error'}
label={item.level.toUpperCase()}
/>
)
},
{ header: "Logger", accessor: "logger", className: "font-mono text-sm color-[var(--primary)]" },
{ header: "Nachricht", accessor: "message" },
]}
/>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { ArrowRight, Bell, CheckCircle, AlertTriangle, AlertCircle } from "lucide-react";
import clsx from "clsx";
interface Notification {
id: string;
type: "info" | "warning" | "error" | "success";
title: string;
message: string;
timestamp: string;
read: boolean;
}
const MOCK_NOTIFICATIONS: Notification[] = [
{ id: "1", type: "warning", title: "Certificate Expiring Soon", message: "The certificate for blog.mysite.net will expire in 3 days.", timestamp: "2 hours ago", read: false },
{ id: "2", type: "error", title: "Proxy Connection Failed", message: "Failed to connect to upstream 192.168.1.50:8080. Connection refused.", timestamp: "5 hours ago", read: false },
{ id: "3", type: "success", title: "Caddy Updated", message: "Successfully updated Caddy to v2.7.5.", timestamp: "1 day ago", read: true },
{ id: "4", type: "info", title: "New Domain Added", message: "Domain example.com has been configured.", timestamp: "2 days ago", read: true },
];
export default function NotificationsPage() {
return (
<div style={{ maxWidth: '800px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-lg)' }}>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Benachrichtigungen</h1>
<p style={{ color: 'var(--text-muted)' }}>Systemwarnungen und Nachrichten</p>
</div>
<button style={{
padding: '8px 16px',
backgroundColor: 'var(--bg-surface-hover)',
color: 'var(--text-main)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.875rem'
}}>
Alle als gelesen markieren
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-md)' }}>
{MOCK_NOTIFICATIONS.map((notif) => (
<div
key={notif.id}
style={{
display: 'flex',
gap: 'var(--spacing-md)',
padding: 'var(--spacing-lg)',
backgroundColor: notif.read ? 'transparent' : 'var(--bg-surface)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-lg)',
opacity: notif.read ? 0.7 : 1,
}}
>
<div style={{
padding: '8px',
borderRadius: '50%',
backgroundColor: 'var(--bg-main)',
border: '1px solid var(--border-color)',
height: 'fit-content'
}}>
{notif.type === 'error' ? <AlertCircle size={20} color="var(--status-error)" /> :
notif.type === 'warning' ? <AlertTriangle size={20} color="var(--status-warning)" /> :
notif.type === 'success' ? <CheckCircle size={20} color="var(--status-success)" /> :
<Bell size={20} color="var(--text-main)" />}
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--text-main)' }}>{notif.title}</h3>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{notif.timestamp}</span>
</div>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', lineHeight: '1.5' }}>{notif.message}</p>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,141 +1,75 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
.container {
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
flex-direction: column;
gap: var(--spacing-xl);
}
.main {
.header {
display: flex;
min-height: 100vh;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.title {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.03em;
}
.subtitle {
color: var(--text-muted);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--spacing-lg);
}
.sections {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--spacing-lg);
}
@media (max-width: 1024px) {
.sections {
grid-template-columns: 1fr;
}
}
.placeholderWidget {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.widgetHeader {
display: flex;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
.widgetTitle {
font-weight: 600;
font-size: 1.1rem;
}
a.secondary {
border-color: var(--button-secondary-border);
.widgetIcon {
color: var(--text-muted);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}
.widgetContent {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-app);
border-radius: var(--radius-md);
color: var(--text-muted);
min-height: 200px;
}

View File

@@ -1,66 +1,75 @@
import Image from "next/image";
import { StatusCard } from "@/components/Dashboard/StatusCard";
import { LogPreview } from "@/components/Dashboard/LogPreview";
import { SystemStatsWidget } from "@/components/Dashboard/SystemStatsWidget";
import { Activity, Server, Globe, ShieldCheck, ArrowUpRight } from "lucide-react";
import styles from "./page.module.css";
export default function Home() {
const MOCK_LOGS = [
{ id: "1", timestamp: "2023-10-27 10:42:01", level: "info" as const, message: "http.log.access.log0 [INFO] handled request" },
{ id: "2", timestamp: "2023-10-27 10:42:05", level: "info" as const, message: "http.log.access.log0 [INFO] handled request" },
{ id: "3", timestamp: "2023-10-27 10:45:12", level: "warn" as const, message: "http.handlers.reverse_proxy [WARN] upstream unstructured" },
{ id: "4", timestamp: "2023-10-27 10:46:00", level: "error" as const, message: "http.handlers.reverse_proxy [ERROR] dial tcp 127.0.0.1:8080: connect: connection refused" },
{ id: "5", timestamp: "2023-10-27 10:46:01", level: "info" as const, message: "http.log.access.log0 [INFO] handled request" },
];
export default function Dashboard() {
return (
<div className={styles.page}>
<main className={styles.main}>
<Image
className={styles.logo}
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
<div className={styles.container}>
<div className={styles.header}>
<h1 className={styles.title}>Dashboard</h1>
<div className={styles.subtitle}>Überblick über Ihren Caddy Server Status</div>
</div>
<div className={styles.grid}>
<StatusCard
label="Anfragen (24h)"
value="1.2M"
icon={Activity}
trend="+12%"
status="success"
href="/logs"
/>
<div className={styles.intro}>
<h1>To get started, edit the page.tsx file.</h1>
<p>
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className={styles.secondary}
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
<StatusCard
label="Aktive Proxies"
value="8"
icon={Globe}
status="neutral"
href="/proxy"
/>
<StatusCard
label="Domains"
value="12"
icon={Server}
status="neutral"
href="/domains"
/>
<StatusCard
label="API Routen"
value="5"
icon={Server}
status="neutral"
href="/api"
/>
<StatusCard
label="Zertifikate"
value="12 Gültig"
icon={ShieldCheck}
status="warning"
trend="1 läuft bald ab"
href="/certificates"
/>
</div>
<div className={styles.sections}>
<LogPreview logs={MOCK_LOGS} />
<SystemStatsWidget stats={[
{ label: "CPU Auslastung", value: 45, displayValue: "45%" },
{ label: "RAM Nutzung", value: 32, displayValue: "2.5 GB / 8 GB" },
{ label: "Speicherplatz", value: 78, displayValue: "142 GB belegt" },
{ label: "System Laufzeit", value: 99, displayValue: "14t 2h 12m" }
]} />
</div>
</div>
);
}

65
src/app/plugins/page.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { DataTable, StatusBadge } from "@/components/Common/DataTable";
import { Download, Trash2 } from "lucide-react";
interface Plugin {
id: string;
name: string;
version: string;
description: string;
author: string;
status: "installed" | "update_available";
}
const MOCK_PLUGINS: Plugin[] = [
{ id: "1", name: "caddy-dns-cloudflare", version: "v1.2.0", description: "Cloudflare DNS provider for Caddy", author: "caddy-dns", status: "installed" },
{ id: "2", name: "caddy-security", version: "v1.0.1", description: "Security plugin for Caddy", author: "greenpau", status: "update_available" },
{ id: "3", name: "caddy-ratelimit", version: "v0.9.5", description: "Rate limiting middleware", author: "mholt", status: "installed" },
];
export default function PluginsPage() {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-lg)' }}>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Plugins</h1>
<p style={{ color: 'var(--text-muted)' }}>Installierte Caddy Module verwalten</p>
</div>
</div>
<DataTable
data={MOCK_PLUGINS}
keyField="id"
columns={[
{ header: "Plugin", accessor: "name", className: "font-mono" },
{ header: "Version", accessor: "version" },
{ header: "Beschreibung", accessor: "description" },
{ header: "Autor", accessor: "author" },
{
header: "Status",
accessor: (item) => (
<StatusBadge
status={item.status === 'installed' ? 'success' : 'warning'}
label={item.status === 'installed' ? 'INSTALLIERT' : 'UPDATE VERFÜGBAR'}
/>
)
},
{
header: "Actions",
accessor: (item) => (
<div style={{ display: 'flex', gap: '8px' }}>
{item.status === 'update_available' && (
<button title="Update" style={{ padding: '4px', background: 'transparent', border: 'none', color: 'var(--status-warning)', cursor: 'pointer' }}>
<Download size={18} />
</button>
)}
<button title="Uninstall" style={{ padding: '4px', background: 'transparent', border: 'none', color: 'var(--status-error)', cursor: 'pointer' }}>
<Trash2 size={18} />
</button>
</div>
)
},
]}
/>
</div>
);
}

170
src/app/proxy/page.tsx Normal file
View File

@@ -0,0 +1,170 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { DataTable, StatusBadge } from "@/components/Common/DataTable";
import { Plus, Edit2, Trash2, FileText, Globe, ShieldCheck } from "lucide-react";
import { ProxyEditor } from "@/components/Proxy/ProxyEditor";
import { DirectiveData } from "@/components/Proxy/DirectiveBlock";
interface ProxyRule {
id: string;
matcher: string;
upstream: string;
loadBalancing: string;
status: "active" | "inactive";
directives: DirectiveData[];
}
const MOCK_PROXIES: ProxyRule[] = [
{ id: "1", matcher: "/api/*", upstream: "localhost:3000", loadBalancing: "Round Robin", status: "active", directives: [] },
{ id: "2", matcher: "/blog", upstream: "wordpress-container:80", loadBalancing: "Random", status: "active", directives: [] },
{ id: "3", matcher: "*.example.com", upstream: "192.168.1.50:8080", loadBalancing: "Least Conn", status: "inactive", directives: [] },
];
import { ToggleSwitch } from "@/components/Common/ToggleSwitch";
export default function ProxyPage() {
const [proxies, setProxies] = useState<ProxyRule[]>(MOCK_PROXIES);
const [isEditing, setIsEditing] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const handleAdd = () => {
setEditingId(null);
setIsEditing(true);
};
const handleEdit = (id: string) => {
setEditingId(id);
setIsEditing(true);
};
const handleDelete = (id: string) => {
// Confirm dialog removed for better UX or replaced by custom modal
setProxies(proxies.filter(p => p.id !== id));
};
const handleSave = (matcher: string, directives: DirectiveData[]) => {
if (editingId) {
setProxies(proxies.map(p => p.id === editingId ? { ...p, matcher, directives } : p));
} else {
const newRule: ProxyRule = {
id: Math.random().toString(36).substr(2, 9),
matcher,
upstream: directives.find(d => d.type === 'reverse_proxy')?.args.upstream || "pending-config",
loadBalancing: directives.find(d => d.type === 'reverse_proxy')?.args.lb_policy || "Round Robin",
status: "active",
directives
};
setProxies([...proxies, newRule]);
}
setIsEditing(false);
};
const currentRule = editingId ? proxies.find(p => p.id === editingId) : null;
if (isEditing) {
return (
<div>
<div style={{ marginBottom: 'var(--spacing-lg)' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>{editingId ? 'Proxy Regel bearbeiten' : 'Neue Proxy Regel'}</h1>
<p style={{ color: 'var(--text-muted)' }}>Konfigurieren Sie Direktiven per Drag & Drop</p>
</div>
<ProxyEditor
initialMatcher={currentRule?.matcher}
initialDirectives={currentRule?.directives}
onSave={handleSave}
onCancel={() => setIsEditing(false)}
/>
</div>
);
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--spacing-lg)' }}>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Proxy Regeln</h1>
<p style={{ color: 'var(--text-muted)' }}>Reverse Proxy Konfigurationen verwalten</p>
</div>
<button
onClick={handleAdd}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
backgroundColor: 'var(--primary)',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
<Plus size={16} />
Regel hinzufügen
</button>
</div>
<DataTable
data={proxies}
keyField="id"
columns={[
{ header: "Matcher", accessor: "matcher" },
{ header: "Upstream", accessor: "upstream" },
{ header: "Lastverteilung", accessor: "loadBalancing" },
{
header: "Status",
accessor: (item) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<ToggleSwitch
checked={item.status === 'active'}
onChange={(checked) => {
setProxies(proxies.map(p => p.id === item.id ? { ...p, status: checked ? 'active' : 'inactive' } : p));
}}
/>
<StatusBadge
status={item.status === 'active' ? 'success' : 'neutral'}
label={item.status === 'active' ? 'AKTIV' : 'INAKTIV'}
/>
</div>
)
},
{
header: "Aktionen",
accessor: (item) => (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '4px', marginRight: '8px', borderRight: '1px solid var(--border-color)', paddingRight: '8px' }}>
<Link href={`/logs?filter=${encodeURIComponent(item.matcher)}`} title="Logs ansehen" style={{ color: 'var(--text-muted)', padding: '4px' }}>
<FileText size={16} />
</Link>
<Link href={`/domains?filter=${encodeURIComponent(item.matcher)}`} title="Domains ansehen" style={{ color: 'var(--text-muted)', padding: '4px' }}>
<Globe size={16} />
</Link>
<Link href={`/certs?filter=${encodeURIComponent(item.matcher)}`} title="Zertifikate ansehen" style={{ color: 'var(--text-muted)', padding: '4px' }}>
<ShieldCheck size={16} />
</Link>
</div>
<button
onClick={() => handleEdit(item.id)}
title="Edit"
style={{ padding: '4px', background: 'transparent', border: 'none', color: 'var(--text-main)', cursor: 'pointer' }}
>
<Edit2 size={18} />
</button>
<button
onClick={() => handleDelete(item.id)}
title="Delete"
style={{ padding: '4px', background: 'transparent', border: 'none', color: 'var(--status-error)', cursor: 'pointer' }}
>
<Trash2 size={18} />
</button>
</div>
)
}
]}
/>
</div>
);
}

87
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,87 @@
"use client";
import { Save } from "lucide-react";
export default function SettingsPage() {
return (
<div style={{ maxWidth: '800px' }}>
<div style={{ marginBottom: 'var(--spacing-lg)' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Globale Einstellungen</h1>
<p style={{ color: 'var(--text-muted)' }}>Allgemeine Caddy-Parameter konfigurieren</p>
</div>
<div style={{
backgroundColor: 'var(--bg-surface)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--spacing-lg)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-lg)'
}}>
<div>
<h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>Admin Interface</h2>
<div style={{ display: 'grid', gap: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ fontSize: '0.875rem', fontWeight: 500 }}>Admin Endpoint</label>
<input
type="text"
defaultValue="localhost:2019"
style={{
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid var(--border-color)',
backgroundColor: 'var(--bg-main)',
color: 'var(--text-main)'
}}
/>
</div>
</div>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)' }} />
<div>
<h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>Sicherheit</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<input type="checkbox" id="auto_https" defaultChecked />
<label htmlFor="auto_https" style={{ fontSize: '0.875rem' }}>Automatisches HTTPS (Let's Encrypt) aktivieren</label>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: 'var(--spacing-md)' }}>
<label style={{ fontSize: '0.875rem', fontWeight: 500 }}>ACME E-Mail</label>
<input
type="email"
defaultValue="admin@example.com"
style={{
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid var(--border-color)',
backgroundColor: 'var(--bg-main)',
color: 'var(--text-main)'
}}
/>
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>E-Mail-Adresse für ACME-Registrierung und Wiederherstellung.</p>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 'var(--spacing-md)' }}>
<button style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 24px',
backgroundColor: 'var(--primary)',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 500
}}>
<Save size={18} />
Speichern
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 50;
backdrop-filter: blur(4px);
}
.modal {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
width: 100%;
max-width: 600px;
padding: var(--spacing-lg);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
max-height: 90vh;
overflow-y: auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-main);
}
.form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.inputGroup {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
}
.input,
.textarea,
.select {
padding: 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background-color: var(--bg-main);
color: var(--text-main);
font-size: 0.95rem;
font-family: inherit;
}
.textarea {
min-height: 100px;
resize: vertical;
}
.input:focus,
.textarea:focus,
.select:focus {
outline: none;
border-color: var(--primary);
}
.row {
display: flex;
gap: var(--spacing-md);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
margin-top: var(--spacing-sm);
}
.btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s;
}
.btnSecondary {
background-color: transparent;
border-color: var(--border-color);
color: var(--text-main);
}
.btnSecondary:hover {
background-color: var(--bg-surface-hover);
}
.btnPrimary {
background-color: var(--primary);
color: white;
}
.btnPrimary:hover {
opacity: 0.9;
}

View File

@@ -0,0 +1,142 @@
import React, { useState, useEffect } from "react";
import styles from "./ApiEndpointModal.module.css";
import { X } from "lucide-react";
export interface ApiEndpoint {
id: string;
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
path: string;
description: string;
headers?: string; // JSON string for simplicity in mock
body?: string;
}
interface ApiEndpointModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (data: ApiEndpoint) => void;
initialData?: ApiEndpoint | null;
}
export function ApiEndpointModal({ isOpen, onClose, onSave, initialData }: ApiEndpointModalProps) {
const [method, setMethod] = useState<ApiEndpoint["method"]>("GET");
const [path, setPath] = useState("");
const [description, setDescription] = useState("");
const [headers, setHeaders] = useState("{}");
const [body, setBody] = useState("");
useEffect(() => {
if (initialData) {
setMethod(initialData.method);
setPath(initialData.path);
setDescription(initialData.description);
setHeaders(initialData.headers || "{}");
setBody(initialData.body || "");
} else {
setMethod("GET");
setPath("");
setDescription("");
setHeaders("{}");
setBody("");
}
}, [initialData, isOpen]);
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
id: initialData?.id || Math.random().toString(36).substr(2, 9),
method,
path,
description,
headers,
body
});
onClose();
};
return (
<div className={styles.overlay}>
<div className={styles.modal}>
<div className={styles.header}>
<h2 className={styles.title}>{initialData ? "Endpoint bearbeiten" : "Neuen Endpoint definieren"}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.row}>
<div className={styles.inputGroup} style={{ width: '120px' }}>
<label className={styles.label}>Methode</label>
<select
className={styles.select}
value={method}
onChange={(e) => setMethod(e.target.value as any)}
>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select>
</div>
<div className={styles.inputGroup} style={{ flex: 1 }}>
<label className={styles.label}>Pfad / URL</label>
<input
className={styles.input}
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/api/v1/resource"
required
/>
</div>
</div>
<div className={styles.inputGroup}>
<label className={styles.label}>Beschreibung</label>
<input
className={styles.input}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Daten abrufen..."
/>
</div>
<div className={styles.inputGroup}>
<label className={styles.label}>Header (JSON)</label>
<textarea
className={styles.textarea}
value={headers}
onChange={(e) => setHeaders(e.target.value)}
placeholder='{"Authorization": "Bearer token"}'
style={{ minHeight: '80px', fontFamily: 'monospace', fontSize: '0.85rem' }}
/>
</div>
{(method === "POST" || method === "PUT" || method === "PATCH") && (
<div className={styles.inputGroup}>
<label className={styles.label}>Body (JSON)</label>
<textarea
className={styles.textarea}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder='{"key": "value"}'
style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
/>
</div>
)}
<div className={styles.actions}>
<button type="button" className={`${styles.btn} ${styles.btnSecondary}`} onClick={onClose}>
Abbrechen
</button>
<button type="submit" className={`${styles.btn} ${styles.btnPrimary}`}>
Endpoint speichern
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import React, { useState } from "react";
import styles from "./ApiEndpointModal.module.css"; // Reuse styles
import { X, Play, Loader2 } from "lucide-react";
import { ApiEndpoint } from "./ApiEndpointModal";
interface ApiTestModalProps {
isOpen: boolean;
onClose: () => void;
endpoint: ApiEndpoint | null;
}
export function ApiTestModal({ isOpen, onClose, endpoint }: ApiTestModalProps) {
const [loading, setLoading] = useState(false);
const [response, setResponse] = useState<any>(null);
const [status, setStatus] = useState<number | null>(null);
if (!isOpen || !endpoint) return null;
const handleRun = async () => {
setLoading(true);
setResponse(null);
setStatus(null);
// Simulate API call
setTimeout(() => {
setLoading(false);
setStatus(200);
setResponse({
message: "Success",
data: {
id: 123,
name: "Test Item",
timestamp: new Date().toISOString()
}
});
}, 800);
};
return (
<div className={styles.overlay}>
<div className={styles.modal} style={{ maxWidth: '700px' }}>
<div className={styles.header}>
<h2 className={styles.title}>Endpoint testen</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>
<X size={20} />
</button>
</div>
<div className={styles.form}>
<div style={{ padding: '12px', backgroundColor: 'var(--bg-main)', borderRadius: '6px', border: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
<span style={{
fontWeight: 600,
color: endpoint.method === 'GET' ? 'var(--primary)' : endpoint.method === 'POST' ? 'var(--status-success)' : 'var(--text-main)'
}}>
{endpoint.method}
</span>
<span style={{ fontFamily: 'monospace', color: 'var(--text-main)' }}>{endpoint.path}</span>
</div>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{endpoint.description}</p>
</div>
<div className={styles.actions} style={{ marginTop: 0 }}>
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={handleRun}
disabled={loading}
style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%', justifyContent: 'center' }}
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
{loading ? "Ausführen..." : "Request ausführen"}
</button>
</div>
{status !== null && (
<div style={{ marginTop: 'var(--spacing-md)', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>Status:</span>
<span style={{
padding: '2px 6px',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 600,
backgroundColor: status >= 200 && status < 300 ? 'rgba(76, 175, 80, 0.1)' : 'rgba(239, 68, 68, 0.1)',
color: status >= 200 && status < 300 ? 'var(--status-success)' : 'var(--status-error)'
}}>
{status} OK
</span>
</div>
<div className={styles.inputGroup}>
<label className={styles.label}>Response Body</label>
<pre style={{
padding: '12px',
borderRadius: '6px',
backgroundColor: '#1e1e1e', // Force dark bg for code
color: '#d4d4d4',
fontFamily: 'monospace',
fontSize: '0.85rem',
overflowX: 'auto',
border: '1px solid var(--border-color)'
}}>
{JSON.stringify(response, null, 2)}
</pre>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
.container {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table th {
text-align: left;
padding: var(--spacing-md) var(--spacing-lg);
background-color: var(--bg-surface-hover);
color: var(--text-muted);
font-weight: 500;
border-bottom: 1px solid var(--border-color);
}
.table td {
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
color: var(--text-main);
}
.table tr:last-child td {
border-bottom: none;
}
.table tr:hover td {
background-color: rgba(255, 255, 255, 0.02);
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
}
.statusBadge.success {
background-color: rgba(34, 197, 94, 0.1);
color: var(--status-success);
}
.statusBadge.warning {
background-color: rgba(234, 179, 8, 0.1);
color: var(--status-warning);
}
.statusBadge.error {
background-color: rgba(239, 68, 68, 0.1);
color: var(--status-error);
}
.statusBadge.neutral {
background-color: var(--bg-surface-hover);
color: var(--text-muted);
}

View File

@@ -0,0 +1,54 @@
import React from "react";
import clsx from "clsx";
import styles from "./DataTable.module.css";
interface Column<T> {
header: string;
accessor: keyof T | ((item: T) => React.ReactNode);
className?: string;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
keyField: keyof T;
}
export function DataTable<T>({ data, columns, keyField }: DataTableProps<T>) {
return (
<div className={styles.container}>
<table className={styles.table}>
<thead>
<tr>
{columns.map((col, index) => (
<th key={index} className={col.className}>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((item) => (
<tr key={String(item[keyField])}>
{columns.map((col, index) => (
<td key={index} className={col.className}>
{typeof col.accessor === "function"
? col.accessor(item)
: (item[col.accessor] as React.ReactNode)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function StatusBadge({ status, label }: { status: "success" | "warning" | "error" | "neutral"; label: string }) {
return (
<span className={clsx(styles.statusBadge, styles[status])}>
{label}
</span>
);
}

View File

@@ -0,0 +1,55 @@
.toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.slider {
width: 44px;
height: 24px;
background-color: var(--bg-surface-hover);
border-radius: 9999px;
position: relative;
transition: background-color 0.2s;
border: 1px solid var(--border-color);
}
.input:checked+.slider {
background-color: var(--primary);
border-color: var(--primary);
}
.knob {
width: 18px;
height: 18px;
background-color: white;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.input:checked+.slider .knob {
transform: translateX(20px);
}
.label {
margin-left: 8px;
font-size: 0.875rem;
color: var(--text-main);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import styles from './ToggleSwitch.module.css';
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
label?: string;
}
export function ToggleSwitch({ checked, onChange, label }: ToggleSwitchProps) {
return (
<label className={styles.toggle}>
<input
type="checkbox"
className={styles.input}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<div className={styles.slider}>
<div className={styles.knob}></div>
</div>
{label && <span className={styles.label}>{label}</span>}
</label>
);
}

View File

@@ -0,0 +1,81 @@
.container {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
flex: 1;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-weight: 600;
color: var(--text-main);
}
.link {
display: flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--color-primary);
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
.link:hover {
opacity: 0.8;
}
.content {
background-color: var(--bg-app);
border-radius: var(--radius-md);
padding: var(--spacing-md);
font-family: var(--font-mono);
font-size: 0.8125rem;
overflow-x: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.row {
display: flex;
gap: var(--spacing-md);
white-space: nowrap;
}
.timestamp {
color: var(--text-muted);
}
.level {
font-weight: 700;
min-width: 40px;
}
.level.info {
color: var(--text-main);
}
.level.warn {
color: var(--status-warning);
}
.level.error {
color: var(--status-error);
}
.message {
color: var(--text-secondary);
}

View File

@@ -0,0 +1,41 @@
import Link from "next/link";
import { ArrowRight, FileText } from "lucide-react";
import clsx from "clsx";
import styles from "./LogPreview.module.css";
interface LogEntry {
id: string;
timestamp: string;
level: "info" | "warn" | "error";
message: string;
}
interface LogPreviewProps {
logs: LogEntry[];
}
export function LogPreview({ logs }: LogPreviewProps) {
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.title}>
<FileText size={20} />
<span>Recent Logs</span>
</div>
<Link href="/logs" className={styles.link}>
<span>View All</span>
<ArrowRight size={16} />
</Link>
</div>
<div className={styles.content}>
{logs.map((log) => (
<div key={log.id} className={styles.row}>
<span className={styles.timestamp}>{log.timestamp}</span>
<span className={clsx(styles.level, styles[log.level])}>{log.level.toUpperCase()}</span>
<span className={styles.message}>{log.message}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
.card {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
transition: transform 0.2s ease, box-shadow 0.2s ease;
height: 100%;
justify-content: space-between;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
border-color: var(--border-light);
}
.clickable {
cursor: pointer;
}
.clickable:hover {
border-color: var(--primary);
background-color: var(--bg-surface-hover);
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.label {
color: var(--text-muted);
font-size: 0.875rem;
font-weight: 500;
}
.iconWrapper {
padding: var(--spacing-sm);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
}
.iconWrapper.neutral {
background-color: var(--bg-surface-hover);
color: var(--text-main);
}
.iconWrapper.success {
background-color: rgba(34, 197, 94, 0.1);
color: var(--status-success);
}
.iconWrapper.warning {
background-color: rgba(234, 179, 8, 0.1);
color: var(--status-warning);
}
.iconWrapper.error {
background-color: rgba(239, 68, 68, 0.1);
color: var(--status-error);
}
.content {
display: flex;
align-items: baseline;
gap: var(--spacing-sm);
}
.value {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-main);
letter-spacing: -0.02em;
}
.trend {
font-size: 0.875rem;
color: var(--status-success);
}

View File

@@ -0,0 +1,41 @@
import clsx from "clsx";
import styles from "./StatusCard.module.css";
import { LucideIcon } from "lucide-react";
interface StatusCardProps {
label: string;
value: string | number;
icon: LucideIcon;
status?: "success" | "warning" | "error" | "neutral";
trend?: string;
href?: string;
}
import Link from "next/link";
export function StatusCard({ label, value, icon: Icon, status = "neutral", trend, href }: StatusCardProps) {
const content = (
<div className={clsx(styles.card, href && styles.clickable)}>
<div className={styles.header}>
<span className={styles.label}>{label}</span>
<div className={clsx(styles.iconWrapper, styles[status])}>
<Icon size={20} />
</div>
</div>
<div className={styles.content}>
<div className={styles.value}>{value}</div>
{trend && <div className={styles.trend}>{trend}</div>}
</div>
</div>
);
if (href) {
return (
<Link href={href} style={{ textDecoration: 'none' }}>
{content}
</Link>
);
}
return content;
}

View File

@@ -0,0 +1,70 @@
.container {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
color: var(--text-muted);
font-size: 0.875rem;
font-weight: 500;
margin-bottom: var(--spacing-sm);
}
.title {
color: var(--text-main);
font-weight: 600;
font-size: 1rem;
}
.grid {
display: grid;
gap: var(--spacing-md);
}
.statItem {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.statHeader {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: var(--text-muted);
}
.statValue {
color: var(--text-main);
font-weight: 500;
}
.progressBar {
height: 6px;
background-color: var(--bg-surface-hover);
border-radius: 999px;
overflow: hidden;
}
.progressFill {
height: 100%;
background-color: var(--primary);
border-radius: 999px;
transition: width 0.5s ease;
}
.progressFill.high {
background-color: var(--status-warning);
}
.progressFill.critical {
background-color: var(--status-error);
}

View File

@@ -0,0 +1,43 @@
import clsx from "clsx";
import { Server } from "lucide-react";
import styles from "./SystemStatsWidget.module.css";
interface SystemStat {
label: string;
value: number; // 0 to 100
displayValue: string;
}
interface SystemStatsWidgetProps {
stats: SystemStat[];
}
export function SystemStatsWidget({ stats }: SystemStatsWidgetProps) {
return (
<div className={styles.container}>
<div className={styles.header}>
<Server size={20} />
<span className={styles.title}>System Status</span>
</div>
<div className={styles.grid}>
{stats.map((stat, index) => (
<div key={index} className={styles.statItem}>
<div className={styles.statHeader}>
<span>{stat.label}</span>
<span className={styles.statValue}>{stat.displayValue}</span>
</div>
<div className={styles.progressBar}>
<div
className={clsx(styles.progressFill, {
[styles.high]: stat.value > 70 && stat.value <= 90,
[styles.critical]: stat.value > 90,
})}
style={{ width: `${stat.value}%` }}
/>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 50;
backdrop-filter: blur(4px);
}
.modal {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
width: 100%;
max-width: 500px;
padding: var(--spacing-lg);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-main);
}
.form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.inputGroup {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
}
.input {
padding: 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background-color: var(--bg-main);
color: var(--text-main);
font-size: 0.95rem;
}
.input:focus {
outline: none;
border-color: var(--primary);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
margin-top: var(--spacing-lg);
}
.btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s;
}
.btnSecondary {
background-color: transparent;
border-color: var(--border-color);
color: var(--text-main);
}
.btnSecondary:hover {
background-color: var(--bg-surface-hover);
}
.btnPrimary {
background-color: var(--primary);
color: white;
}
.btnPrimary:hover {
opacity: 0.9;
}

View File

@@ -0,0 +1,101 @@
import React, { useState, useEffect } from "react";
import styles from "./DomainModal.module.css";
import { ToggleSwitch } from "@/components/Common/ToggleSwitch";
import { X } from "lucide-react";
export interface DomainData {
id: string;
domain: string;
port: string;
ssl: boolean;
status: "active" | "inactive" | "error";
}
interface DomainModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (data: DomainData) => void;
initialData?: DomainData | null;
}
export function DomainModal({ isOpen, onClose, onSave, initialData }: DomainModalProps) {
const [domain, setDomain] = useState("");
const [port, setPort] = useState("443");
const [ssl, setSsl] = useState(true);
useEffect(() => {
if (initialData) {
setDomain(initialData.domain);
setPort(initialData.port);
setSsl(initialData.ssl);
} else {
setDomain("");
setPort("443");
setSsl(true);
}
}, [initialData, isOpen]);
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
id: initialData?.id || Math.random().toString(36).substr(2, 9),
domain,
port,
ssl,
status: initialData?.status || "active",
});
onClose();
};
return (
<div className={styles.overlay}>
<div className={styles.modal}>
<div className={styles.header}>
<h2 className={styles.title}>{initialData ? "Domain bearbeiten" : "Neue Domain hinzufügen"}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.inputGroup}>
<label className={styles.label}>Domain Name</label>
<input
className={styles.input}
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder="z.B. example.com"
required
/>
</div>
<div className={styles.inputGroup}>
<label className={styles.label}>Port</label>
<input
className={styles.input}
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="z.B. 443"
/>
</div>
<div className={styles.inputGroup}>
<label className={styles.label}>HTTPS / SSL</label>
<ToggleSwitch
checked={ssl}
onChange={setSsl}
label={ssl ? "Aktiviert" : "Deaktiviert"}
/>
</div>
<div className={styles.actions}>
<button type="button" className={`${styles.btn} ${styles.btnSecondary}`} onClick={onClose}>
Abbrechen
</button>
<button type="submit" className={`${styles.btn} ${styles.btnPrimary}`}>
Speichern
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
.container {
display: flex;
min-height: 100vh;
background-color: var(--bg-app);
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100vh;
}
.content {
flex: 1;
padding: var(--spacing-xl);
max-width: 1400px;
margin: 0 auto;
width: 100%;
}

View File

@@ -0,0 +1,15 @@
import { Sidebar } from "./Sidebar";
import styles from "./AppLayout.module.css";
export function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className={styles.container}>
<Sidebar />
<main className={styles.main}>
<div className={styles.content}>
{children}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,100 @@
.sidebar {
width: 260px;
height: 100vh;
background-color: var(--bg-surface);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: var(--spacing-md);
flex-shrink: 0;
}
.header {
padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-xl);
}
.logo {
display: flex;
align-items: center;
gap: var(--spacing-md);
font-weight: 700;
font-size: 1.25rem;
color: var(--text-main);
letter-spacing: -0.02em;
}
.logoIcon {
color: var(--color-primary);
}
.nav {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
flex: 1;
}
.link {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
border-radius: var(--radius-md);
color: var(--text-muted);
transition: all 0.2s ease;
font-weight: 500;
}
.link:hover {
background-color: var(--bg-surface-hover);
color: var(--text-main);
}
.active {
background-color: var(--bg-surface-active);
color: var(--color-primary);
}
.footer {
padding-top: var(--spacing-md);
border-top: 1px solid var(--border-color);
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: 0.875rem;
color: var(--text-muted);
}
.statusDot {
width: 8px;
height: 8px;
background-color: var(--status-success);
border-radius: 50%;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
}
.notificationWrapper {
position: relative;
display: flex;
align-items: center;
}
.notificationBadge {
position: absolute;
top: -4px;
right: -4px;
background-color: var(--status-error);
color: white;
font-size: 0.625rem;
font-weight: 700;
min-width: 14px;
height: 14px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
}

View File

@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Network,
ShieldCheck,
FileText,
Settings,
Globe,
Code,
Package,
Bell,
Server
} from "lucide-react";
import clsx from "clsx";
import styles from "./Sidebar.module.css";
const MENU_ITEMS = [
{ label: "Dashboard", href: "/", icon: LayoutDashboard },
{ label: "Einstellungen", href: "/settings", icon: Settings },
{ label: "Domains", href: "/domains", icon: Globe },
{ label: "Proxy Regeln", href: "/proxy", icon: Network },
{ label: "Zertifikate", href: "/certs", icon: ShieldCheck },
{ label: "Logs", href: "/logs", icon: FileText },
{ label: "API Explorer", href: "/api", icon: Code },
{ label: "Plugins", href: "/plugins", icon: Package },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className={styles.sidebar}>
<div className={styles.header}>
<div className={styles.logo}>
<Server className={styles.logoIcon} size={28} />
<span>CaddyPanel</span>
</div>
</div>
<nav className={styles.nav}>
{MENU_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={clsx(styles.link, isActive && styles.active)}
>
<Icon size={20} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className={styles.footer}>
<Link
href="/notifications"
className={clsx(styles.link, pathname === "/notifications" && styles.active)}
style={{ marginBottom: 'var(--spacing-md)' }}
>
<div className={styles.notificationWrapper}>
<Bell size={20} />
<span className={styles.notificationBadge}>3</span>
</div>
<span>Benachrichtigungen</span>
</Link>
<div className={styles.status}>
<div className={styles.statusDot} />
<span>System Online</span>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,88 @@
.block {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
position: relative;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.block:hover {
border-color: var(--border-light);
}
.dragging {
opacity: 0.5;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
cursor: grabbing;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: grab;
}
.handle {
color: var(--text-muted);
}
.title {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-main);
display: flex;
align-items: center;
gap: 8px;
}
.content {
display: flex;
flex-direction: column;
gap: 8px;
}
.inputGroup {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 0.75rem;
color: var(--text-muted);
}
.input {
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border-color);
background-color: var(--bg-main);
color: var(--text-main);
font-size: 0.875rem;
width: 100%;
}
.input:focus {
outline: none;
border-color: var(--primary);
}
.removeBtn {
padding: 4px;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
}
.removeBtn:hover {
background-color: rgba(239, 68, 68, 0.1);
color: var(--status-error);
}

View File

@@ -0,0 +1,85 @@
import React from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, X } from "lucide-react";
import clsx from "clsx";
import styles from "./DirectiveBlock.module.css";
export interface DirectiveData {
id: string;
type: "header_up" | "header_down" | "rewrite" | "uri" | "try_files" | "reverse_proxy" | "basic_auth" | "encode" | "handle_path" | "route";
args: Record<string, string>;
}
interface DirectiveBlockProps {
data: DirectiveData;
onUpdate: (id: string, args: Record<string, string>) => void;
onRemove: (id: string) => void;
}
const DIRECTIVE_CONFIG = {
header_up: { label: "Header Up (Anfrage)", fields: [{ name: "header", label: "Header" }, { name: "value", label: "Wert" }] },
header_down: { label: "Header Down (Antwort)", fields: [{ name: "header", label: "Header" }, { name: "value", label: "Wert" }] },
rewrite: { label: "Rewrite", fields: [{ name: "to", label: "Nach Pfad" }] },
uri: { label: "URI Manipulation", fields: [{ name: "op", label: "Operation (strip_prefix/replace)" }, { name: "path", label: "Pfad" }] },
try_files: { label: "Try Files", fields: [{ name: "files", label: "Dateien (leerzeichengetrennt)" }] },
reverse_proxy: { label: "Reverse Proxy", fields: [{ name: "upstream", label: "Upstream (host:port)" }, { name: "lb_policy", label: "Lastverteilung" }] },
basic_auth: { label: "Basic Auth", fields: [{ name: "hash", label: "Hash (bcrypt)" }, { name: "salt", label: "Salt (Optional)" }] },
encode: { label: "Encode", fields: [{ name: "formats", label: "Formate (gzip zstd)" }] },
handle_path: { label: "Handle Path", fields: [{ name: "path_matcher", label: "Pfad Matcher" }] },
route: { label: "Route", fields: [] },
};
export function DirectiveBlock({ data, onUpdate, onRemove }: DirectiveBlockProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: data.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const config = DIRECTIVE_CONFIG[data.type];
const handleChange = (field: string, value: string) => {
onUpdate(data.id, { ...data.args, [field]: value });
};
return (
<div
ref={setNodeRef}
style={style}
className={clsx(styles.block, isDragging && styles.dragging)}
>
<div className={styles.header}>
<div className={styles.title} {...attributes} {...listeners}>
<GripVertical size={16} className={styles.handle} />
<span>{config.label}</span>
</div>
<button className={styles.removeBtn} onClick={() => onRemove(data.id)}>
<X size={16} />
</button>
</div>
<div className={styles.content}>
{config.fields.map((field) => (
<div key={field.name} className={styles.inputGroup}>
<label className={styles.label}>{field.label}</label>
<input
className={styles.input}
value={data.args[field.name] || ""}
onChange={(e) => handleChange(field.name, e.target.value)}
placeholder={field.label}
/>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
.wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.topBar {
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--border-color);
}
.inputGroup {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-main);
}
.mainInput {
padding: 10px 14px;
font-size: 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background-color: var(--bg-main);
color: var(--text-main);
width: 100%;
max-width: 600px;
}
.mainInput:focus {
outline: none;
border-color: var(--primary);
}
.container {
display: flex;
gap: var(--spacing-lg);
height: 600px;
}
.sidebar {
width: 280px;
display: flex;
flex-direction: column;
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
}
.sidebarContent {
padding: var(--spacing-md);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.sidebarGroup {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.sidebarGroupTitle {
font-weight: 600;
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding-left: 2px;
}
.templateGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.templateItem {
padding: 8px;
background-color: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.8rem;
color: var(--text-main);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
text-align: center;
transition: all 0.2s;
height: 70px;
}
.templateItem span {
word-break: break-word;
line-height: 1.2;
}
.templateItem:hover {
background-color: var(--bg-surface-hover);
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.canvas {
flex: 1;
background-color: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.canvasEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
border: 2px dashed var(--border-color);
border-radius: var(--radius-md);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
margin-top: var(--spacing-lg);
}
.btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s;
}
.btnSecondary {
background-color: var(--bg-surface);
border-color: var(--border-color);
color: var(--text-main);
}
.btnSecondary:hover {
background-color: var(--bg-surface-hover);
}
.btnPrimary {
background-color: var(--primary);
color: white;
}
.btnPrimary:hover {
opacity: 0.9;
}

View File

@@ -0,0 +1,176 @@
import React, { useState } from "react";
import clsx from "clsx";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragOverlay,
DragStartEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { DirectiveBlock, DirectiveData } from "./DirectiveBlock";
import styles from "./ProxyEditor.module.css";
import { Plus } from "lucide-react";
import { v4 as uuidv4 } from "uuid";
interface ProxyEditorProps {
initialDirectives?: DirectiveData[];
initialMatcher?: string;
onSave: (matcher: string, directives: DirectiveData[]) => void;
onCancel: () => void;
}
const TEMPLATE_GROUPS = {
Routing: [
{ type: "reverse_proxy" },
{ type: "rewrite" },
{ type: "handle_path" },
{ type: "try_files" },
{ type: "route" },
],
Headers: [
{ type: "header_up" },
{ type: "header_down" },
],
Security: [
{ type: "basic_auth" },
],
Other: [
{ type: "uri" },
{ type: "encode" },
]
};
export function ProxyEditor({ initialDirectives = [], initialMatcher = "*", onSave, onCancel }: ProxyEditorProps) {
const [directives, setDirectives] = useState<DirectiveData[]>(initialDirectives);
const [matcher, setMatcher] = useState(initialMatcher);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setDirectives((items) => {
const oldIndex = items.findIndex((i) => i.id === active.id);
const newIndex = items.findIndex((i) => i.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
setActiveId(null);
};
const addDirective = (type: DirectiveData["type"]) => {
const newDirective: DirectiveData = {
id: uuidv4(),
type,
args: {},
};
setDirectives([...directives, newDirective]);
};
const updateDirective = (id: string, args: Record<string, string>) => {
setDirectives(directives.map(d => d.id === id ? { ...d, args } : d));
};
const removeDirective = (id: string) => {
setDirectives(directives.filter(d => d.id !== id));
};
return (
<div className={styles.wrapper}>
<div className={styles.topBar}>
<div className={styles.inputGroup}>
<label className={styles.label}>Match Pattern (Domain / Pfad)</label>
<input
className={styles.mainInput}
value={matcher}
onChange={(e) => setMatcher(e.target.value)}
placeholder="z.B. example.com oder /api/*"
/>
</div>
</div>
<div className={styles.container}>
<aside className={styles.sidebar}>
<div className={styles.sidebarContent}>
{Object.entries(TEMPLATE_GROUPS).map(([category, templates]) => (
<div key={category} className={styles.sidebarGroup}>
<span className={styles.sidebarGroupTitle}>{category}</span>
<div className={styles.templateGrid}>
{templates.map((tmpl) => (
<button
key={tmpl.type}
className={styles.templateItem}
onClick={() => addDirective(tmpl.type as any)}
>
<Plus size={14} />
<span>{tmpl.type}</span>
</button>
))}
</div>
</div>
))}
</div>
</aside>
<div className={styles.canvas}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={directives.map((d) => d.id)}
strategy={verticalListSortingStrategy}
>
{directives.length === 0 ? (
<div className={styles.canvasEmpty}>
<p>Keine Direktiven hinzugefügt. Wählen Sie eine aus der Seitenleiste.</p>
</div>
) : (
directives.map((dir) => (
<DirectiveBlock
key={dir.id}
data={dir}
onUpdate={updateDirective}
onRemove={removeDirective}
/>
))
)}
</SortableContext>
</DndContext>
</div>
</div>
<div className={styles.actions}>
<button className={clsx(styles.btn, styles.btnSecondary)} onClick={onCancel}>
Abbrechen
</button>
<button className={clsx(styles.btn, styles.btnPrimary)} onClick={() => onSave(matcher, directives)}>
Konfiguration speichern
</button>
</div>
</div>
);
}