feat: Implement initial Caddy panel UI with new pages, components, and styling, along with updated dependencies.
This commit is contained in:
45
README.md
45
README.md
@@ -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
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
# oder
|
||||||
yarn dev
|
yarn dev
|
||||||
# or
|
# oder
|
||||||
pnpm dev
|
pnpm dev
|
||||||
# or
|
# oder
|
||||||
bun dev
|
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 14
|
||||||
|
- React
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
- TypeScript
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
- CSS Modules
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
99
package-lock.json
generated
99
package-lock.json
generated
@@ -8,9 +8,16 @@
|
|||||||
"name": "caddy-panel",
|
"name": "caddy-panel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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",
|
"next": "16.0.7",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@@ -261,6 +268,59 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.7.1",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
|
||||||
@@ -1289,6 +1349,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.48.1",
|
"version": "8.48.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
||||||
@@ -2292,6 +2358,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -4264,6 +4339,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -5746,6 +5830,19 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -9,9 +9,16 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "16.0.7",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
165
src/app/api/page.tsx
Normal file
165
src/app/api/page.tsx
Normal 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
58
src/app/certs/page.tsx
Normal 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
154
src/app/domains/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,39 @@
|
|||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
/* Colors */
|
||||||
--foreground: #171717;
|
--bg-app: #0f1014;
|
||||||
}
|
/* Very dark blue-ish grey */
|
||||||
|
--bg-surface: #181b21;
|
||||||
|
--bg-surface-hover: #232730;
|
||||||
|
--bg-surface-active: #2d323b;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
--color-primary: #1F88C0;
|
||||||
:root {
|
/* Caddy "Curious Blue" */
|
||||||
--background: #0a0a0a;
|
--color-primary-dim: #16608a;
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
/* Alias for components expecting --primary */
|
||||||
body {
|
--primary: var(--color-primary);
|
||||||
max-width: 100vw;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
--text-main: #ffffff;
|
||||||
color: var(--foreground);
|
--text-secondary: #cbd5e1;
|
||||||
background: var(--background);
|
--text-muted: #94a3b8;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
--status-success: #22c55e;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
--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;
|
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 {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
button {
|
||||||
html {
|
cursor: pointer;
|
||||||
color-scheme: dark;
|
border: none;
|
||||||
}
|
background: none;
|
||||||
}
|
font-family: inherit;
|
||||||
|
}
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
import type { Metadata } from "next";
|
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";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||||
variable: "--font-geist-sans",
|
const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "CaddyPanel",
|
||||||
description: "Generated by create next app",
|
description: "Modern web interface for Caddy Reverse Proxy",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -24,8 +18,10 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body className={`${inter.variable} ${jetbrainsMono.variable}`}>
|
||||||
{children}
|
<AppLayout>
|
||||||
|
{children}
|
||||||
|
</AppLayout>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
146
src/app/logs/page.tsx
Normal file
146
src/app/logs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/app/notifications/page.tsx
Normal file
79
src/app/notifications/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,141 +1,75 @@
|
|||||||
.page {
|
.container {
|
||||||
--background: #fafafa;
|
|
||||||
--foreground: #fff;
|
|
||||||
|
|
||||||
--text-primary: #000;
|
|
||||||
--text-secondary: #666;
|
|
||||||
|
|
||||||
--button-primary-hover: #383838;
|
|
||||||
--button-secondary-hover: #f2f2f2;
|
|
||||||
--button-secondary-border: #ebebeb;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: var(--spacing-xl);
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--font-geist-sans);
|
|
||||||
background-color: var(--background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 800px;
|
|
||||||
flex-direction: column;
|
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;
|
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;
|
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 {
|
.widgetTitle {
|
||||||
background: var(--text-primary);
|
font-weight: 600;
|
||||||
color: var(--background);
|
font-size: 1.1rem;
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a.secondary {
|
.widgetIcon {
|
||||||
border-color: var(--button-secondary-border);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enable hover only on non-touch devices */
|
.widgetContent {
|
||||||
@media (hover: hover) and (pointer: fine) {
|
flex: 1;
|
||||||
a.primary:hover {
|
display: flex;
|
||||||
background: var(--button-primary-hover);
|
align-items: center;
|
||||||
border-color: transparent;
|
justify-content: center;
|
||||||
}
|
background: var(--bg-app);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
a.secondary:hover {
|
color: var(--text-muted);
|
||||||
background: var(--button-secondary-hover);
|
min-height: 200px;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
127
src/app/page.tsx
127
src/app/page.tsx
@@ -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";
|
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 (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.container}>
|
||||||
<main className={styles.main}>
|
<div className={styles.header}>
|
||||||
<Image
|
<h1 className={styles.title}>Dashboard</h1>
|
||||||
className={styles.logo}
|
<div className={styles.subtitle}>Überblick über Ihren Caddy Server Status</div>
|
||||||
src="/next.svg"
|
</div>
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
<div className={styles.grid}>
|
||||||
height={20}
|
<StatusCard
|
||||||
priority
|
label="Anfragen (24h)"
|
||||||
|
value="1.2M"
|
||||||
|
icon={Activity}
|
||||||
|
trend="+12%"
|
||||||
|
status="success"
|
||||||
|
href="/logs"
|
||||||
/>
|
/>
|
||||||
<div className={styles.intro}>
|
<StatusCard
|
||||||
<h1>To get started, edit the page.tsx file.</h1>
|
label="Aktive Proxies"
|
||||||
<p>
|
value="8"
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
icon={Globe}
|
||||||
<a
|
status="neutral"
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
href="/proxy"
|
||||||
target="_blank"
|
/>
|
||||||
rel="noopener noreferrer"
|
<StatusCard
|
||||||
>
|
label="Domains"
|
||||||
Templates
|
value="12"
|
||||||
</a>{" "}
|
icon={Server}
|
||||||
or the{" "}
|
status="neutral"
|
||||||
<a
|
href="/domains"
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
/>
|
||||||
target="_blank"
|
<StatusCard
|
||||||
rel="noopener noreferrer"
|
label="API Routen"
|
||||||
>
|
value="5"
|
||||||
Learning
|
icon={Server}
|
||||||
</a>{" "}
|
status="neutral"
|
||||||
center.
|
href="/api"
|
||||||
</p>
|
/>
|
||||||
</div>
|
<StatusCard
|
||||||
<div className={styles.ctas}>
|
label="Zertifikate"
|
||||||
<a
|
value="12 Gültig"
|
||||||
className={styles.primary}
|
icon={ShieldCheck}
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
status="warning"
|
||||||
target="_blank"
|
trend="1 läuft bald ab"
|
||||||
rel="noopener noreferrer"
|
href="/certificates"
|
||||||
>
|
/>
|
||||||
<Image
|
</div>
|
||||||
className={styles.logo}
|
|
||||||
src="/vercel.svg"
|
<div className={styles.sections}>
|
||||||
alt="Vercel logomark"
|
<LogPreview logs={MOCK_LOGS} />
|
||||||
width={16}
|
|
||||||
height={16}
|
<SystemStatsWidget stats={[
|
||||||
/>
|
{ label: "CPU Auslastung", value: 45, displayValue: "45%" },
|
||||||
Deploy Now
|
{ label: "RAM Nutzung", value: 32, displayValue: "2.5 GB / 8 GB" },
|
||||||
</a>
|
{ label: "Speicherplatz", value: 78, displayValue: "142 GB belegt" },
|
||||||
<a
|
{ label: "System Laufzeit", value: 99, displayValue: "14t 2h 12m" }
|
||||||
className={styles.secondary}
|
]} />
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
</div>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/app/plugins/page.tsx
Normal file
65
src/app/plugins/page.tsx
Normal 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
170
src/app/proxy/page.tsx
Normal 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
87
src/app/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/Api/ApiEndpointModal.module.css
Normal file
123
src/components/Api/ApiEndpointModal.module.css
Normal 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;
|
||||||
|
}
|
||||||
142
src/components/Api/ApiEndpointModal.tsx
Normal file
142
src/components/Api/ApiEndpointModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/Api/ApiTestModal.tsx
Normal file
111
src/components/Api/ApiTestModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/Common/DataTable.module.css
Normal file
64
src/components/Common/DataTable.module.css
Normal 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);
|
||||||
|
}
|
||||||
54
src/components/Common/DataTable.tsx
Normal file
54
src/components/Common/DataTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/Common/ToggleSwitch.module.css
Normal file
55
src/components/Common/ToggleSwitch.module.css
Normal 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);
|
||||||
|
}
|
||||||
25
src/components/Common/ToggleSwitch.tsx
Normal file
25
src/components/Common/ToggleSwitch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/Dashboard/LogPreview.module.css
Normal file
81
src/components/Dashboard/LogPreview.module.css
Normal 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);
|
||||||
|
}
|
||||||
41
src/components/Dashboard/LogPreview.tsx
Normal file
41
src/components/Dashboard/LogPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/components/Dashboard/StatusCard.module.css
Normal file
85
src/components/Dashboard/StatusCard.module.css
Normal 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);
|
||||||
|
}
|
||||||
41
src/components/Dashboard/StatusCard.tsx
Normal file
41
src/components/Dashboard/StatusCard.tsx
Normal 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;
|
||||||
|
}
|
||||||
70
src/components/Dashboard/SystemStatsWidget.module.css
Normal file
70
src/components/Dashboard/SystemStatsWidget.module.css
Normal 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);
|
||||||
|
}
|
||||||
43
src/components/Dashboard/SystemStatsWidget.tsx
Normal file
43
src/components/Dashboard/SystemStatsWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/components/Domains/DomainModal.module.css
Normal file
104
src/components/Domains/DomainModal.module.css
Normal 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;
|
||||||
|
}
|
||||||
101
src/components/Domains/DomainModal.tsx
Normal file
101
src/components/Domains/DomainModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/Layout/AppLayout.module.css
Normal file
21
src/components/Layout/AppLayout.module.css
Normal 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%;
|
||||||
|
}
|
||||||
15
src/components/Layout/AppLayout.tsx
Normal file
15
src/components/Layout/AppLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/Layout/Sidebar.module.css
Normal file
100
src/components/Layout/Sidebar.module.css
Normal 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;
|
||||||
|
}
|
||||||
81
src/components/Layout/Sidebar.tsx
Normal file
81
src/components/Layout/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/components/Proxy/DirectiveBlock.module.css
Normal file
88
src/components/Proxy/DirectiveBlock.module.css
Normal 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);
|
||||||
|
}
|
||||||
85
src/components/Proxy/DirectiveBlock.tsx
Normal file
85
src/components/Proxy/DirectiveBlock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/components/Proxy/ProxyEditor.module.css
Normal file
172
src/components/Proxy/ProxyEditor.module.css
Normal 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;
|
||||||
|
}
|
||||||
176
src/components/Proxy/ProxyEditor.tsx
Normal file
176
src/components/Proxy/ProxyEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user