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
|
||||
npm run dev
|
||||
# or
|
||||
# oder
|
||||
yarn dev
|
||||
# or
|
||||
# oder
|
||||
pnpm dev
|
||||
# or
|
||||
# oder
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Öffnen Sie [http://localhost:3000](http://localhost:3000) in Ihrem Browser.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
## Funktionen
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
- **Dashboard**: Überblick über Systemstatus und Aktivitäten.
|
||||
- **Proxy Regeln**: Visueller Drag-and-Drop Editor für Reverse-Proxy-Routen.
|
||||
- **Domain Verwaltung**: Einfaches Hinzufügen und Konfigurieren von Domains inkl. SSL.
|
||||
- **Log Viewer**: Detaillierte Log-Analyse mit Filtern für Logger, Inhalt und Zeit.
|
||||
- **API Explorer**: Integriertes Tool zum Testen und Verwalten von API-Endpoints.
|
||||
- **Zertifikate**: Übersicht über aktive SSL-Zertifikate.
|
||||
|
||||
## Learn More
|
||||
## Technologien
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
- Next.js 14
|
||||
- React
|
||||
- TypeScript
|
||||
- CSS Modules
|
||||
|
||||
99
package-lock.json
generated
99
package-lock.json
generated
@@ -8,9 +8,16 @@
|
||||
"name": "caddy-panel",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.556.0",
|
||||
"next": "16.0.7",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
"react-dom": "19.2.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
@@ -261,6 +268,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
|
||||
@@ -1289,6 +1349,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
||||
@@ -2292,6 +2358,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -4264,6 +4339,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.556.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.556.0.tgz",
|
||||
"integrity": "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -5746,6 +5830,19 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -9,9 +9,16 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.556.0",
|
||||
"next": "16.0.7",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
"react-dom": "19.2.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
165
src/app/api/page.tsx
Normal file
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 {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
/* Colors */
|
||||
--bg-app: #0f1014;
|
||||
/* Very dark blue-ish grey */
|
||||
--bg-surface: #181b21;
|
||||
--bg-surface-hover: #232730;
|
||||
--bg-surface-active: #2d323b;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
--color-primary: #1F88C0;
|
||||
/* Caddy "Curious Blue" */
|
||||
--color-primary-dim: #16608a;
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/* Alias for components expecting --primary */
|
||||
--primary: var(--color-primary);
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--text-main: #ffffff;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
|
||||
--status-success: #22c55e;
|
||||
--status-warning: #eab308;
|
||||
--status-error: #ef4444;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
|
||||
/* Borders */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--border-color: #2d323b;
|
||||
--border-light: #3e4552;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -30,13 +42,29 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--text-main);
|
||||
background: var(--bg-app);
|
||||
font-family: var(--font-inter), sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
@@ -1,20 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import { AppLayout } from "@/components/Layout/AppLayout";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "CaddyPanel",
|
||||
description: "Modern web interface for Caddy Reverse Proxy",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,8 +18,10 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
<body className={`${inter.variable} ${jetbrainsMono.variable}`}>
|
||||
<AppLayout>
|
||||
{children}
|
||||
</AppLayout>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
146
src/app/logs/page.tsx
Normal file
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 {
|
||||
--background: #fafafa;
|
||||
--foreground: #fff;
|
||||
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
|
||||
--button-primary-hover: #383838;
|
||||
--button-secondary-hover: #f2f2f2;
|
||||
--button-secondary-border: #ebebeb;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-geist-sans);
|
||||
background-color: var(--background);
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.main {
|
||||
.header {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.sections {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholderWidget {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.widgetHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: var(--foreground);
|
||||
padding: 120px 60px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
max-width: 320px;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 48px;
|
||||
letter-spacing: -2.4px;
|
||||
text-wrap: balance;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.intro p {
|
||||
max-width: 440px;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
text-wrap: balance;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.intro a {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctas {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ctas a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: 128px;
|
||||
border: 1px solid transparent;
|
||||
transition: 0.2s;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a.primary {
|
||||
background: var(--text-primary);
|
||||
color: var(--background);
|
||||
gap: 8px;
|
||||
.widgetTitle {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
border-color: var(--button-secondary-border);
|
||||
.widgetIcon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Enable hover only on non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
a.primary:hover {
|
||||
background: var(--button-primary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
a.secondary:hover {
|
||||
background: var(--button-secondary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.main {
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
letter-spacing: -1.92px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.logo {
|
||||
filter: invert();
|
||||
}
|
||||
|
||||
.page {
|
||||
--background: #000;
|
||||
--foreground: #000;
|
||||
|
||||
--text-primary: #ededed;
|
||||
--text-secondary: #999;
|
||||
|
||||
--button-primary-hover: #ccc;
|
||||
--button-secondary-hover: #1a1a1a;
|
||||
--button-secondary-border: #1a1a1a;
|
||||
}
|
||||
}
|
||||
.widgetContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-app);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
min-height: 200px;
|
||||
}
|
||||
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";
|
||||
|
||||
export default function Home() {
|
||||
const MOCK_LOGS = [
|
||||
{ id: "1", timestamp: "2023-10-27 10:42:01", level: "info" as const, message: "http.log.access.log0 [INFO] handled request" },
|
||||
{ id: "2", timestamp: "2023-10-27 10:42:05", level: "info" as const, message: "http.log.access.log0 [INFO] handled request" },
|
||||
{ id: "3", timestamp: "2023-10-27 10:45:12", level: "warn" as const, message: "http.handlers.reverse_proxy [WARN] upstream unstructured" },
|
||||
{ id: "4", timestamp: "2023-10-27 10:46:00", level: "error" as const, message: "http.handlers.reverse_proxy [ERROR] dial tcp 127.0.0.1:8080: connect: connection refused" },
|
||||
{ id: "5", timestamp: "2023-10-27 10:46:01", level: "info" as const, message: "http.log.access.log0 [INFO] handled request" },
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<main className={styles.main}>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.title}>Dashboard</h1>
|
||||
<div className={styles.subtitle}>Überblick über Ihren Caddy Server Status</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<StatusCard
|
||||
label="Anfragen (24h)"
|
||||
value="1.2M"
|
||||
icon={Activity}
|
||||
trend="+12%"
|
||||
status="success"
|
||||
href="/logs"
|
||||
/>
|
||||
<div className={styles.intro}>
|
||||
<h1>To get started, edit the page.tsx file.</h1>
|
||||
<p>
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.ctas}>
|
||||
<a
|
||||
className={styles.primary}
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className={styles.secondary}
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<StatusCard
|
||||
label="Aktive Proxies"
|
||||
value="8"
|
||||
icon={Globe}
|
||||
status="neutral"
|
||||
href="/proxy"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Domains"
|
||||
value="12"
|
||||
icon={Server}
|
||||
status="neutral"
|
||||
href="/domains"
|
||||
/>
|
||||
<StatusCard
|
||||
label="API Routen"
|
||||
value="5"
|
||||
icon={Server}
|
||||
status="neutral"
|
||||
href="/api"
|
||||
/>
|
||||
<StatusCard
|
||||
label="Zertifikate"
|
||||
value="12 Gültig"
|
||||
icon={ShieldCheck}
|
||||
status="warning"
|
||||
trend="1 läuft bald ab"
|
||||
href="/certificates"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.sections}>
|
||||
<LogPreview logs={MOCK_LOGS} />
|
||||
|
||||
<SystemStatsWidget stats={[
|
||||
{ label: "CPU Auslastung", value: 45, displayValue: "45%" },
|
||||
{ label: "RAM Nutzung", value: 32, displayValue: "2.5 GB / 8 GB" },
|
||||
{ label: "Speicherplatz", value: 78, displayValue: "142 GB belegt" },
|
||||
{ label: "System Laufzeit", value: 99, displayValue: "14t 2h 12m" }
|
||||
]} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
65
src/app/plugins/page.tsx
Normal file
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