From 0b45b23d450c50813d9e4473e2ddb93de6abfe0c Mon Sep 17 00:00:00 2001 From: Debian Date: Sat, 9 May 2026 11:23:00 +0200 Subject: [PATCH] feat(ui): (a) Backends + Routing-Rules + Settings pages + Sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRUD-Pages analog Domains: * Backends: AntD Table + Modal-Form mit name, scheme, address, port, health_check_path, active. TanStack-Query gegen /api/v1/backends. * RoutingRules: Table mit Domain-Name- und Backend-Label-Resolution, Modal mit Select-Pickern für Domain + Backend, Path-Prefix, Priority, Active. Drei parallele Queries (rules, domains, backends) liefern die Listen. * Settings: read-only Descriptions-Cards mit /system/health und /setup/status. Editable Werte folgen später. Sidebar erweitert um Backends, Routing-Rules, Settings (mit AntD- Icons). i18n de/en für alle drei neuen Seiten. bun run build + npx tsc -b strict (0 errors). Live-Smoke gegen API: SPA-Routes /backends, /routing-rules, /settings antworten 200 mit index.html (NoRoute fallback wirkt). Co-Authored-By: Claude Opus 4.7 (1M context) --- management-ui/src/App.tsx | 6 + .../src/components/Layout/Sidebar.tsx | 5 +- management-ui/src/i18n/locales/de/common.json | 43 ++++- management-ui/src/i18n/locales/en/common.json | 43 ++++- management-ui/src/pages/Backends/index.tsx | 159 ++++++++++++++++ .../src/pages/RoutingRules/index.tsx | 172 ++++++++++++++++++ management-ui/src/pages/Settings/index.tsx | 66 +++++++ 7 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 management-ui/src/pages/Backends/index.tsx create mode 100644 management-ui/src/pages/RoutingRules/index.tsx create mode 100644 management-ui/src/pages/Settings/index.tsx diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 3de1763..d0890bd 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -14,6 +14,9 @@ const LoginPage = lazy(() => import('./pages/Login')) const SetupPage = lazy(() => import('./pages/Setup')) const DashboardPage = lazy(() => import('./pages/Dashboard')) const DomainsPage = lazy(() => import('./pages/Domains')) +const BackendsPage = lazy(() => import('./pages/Backends')) +const RoutingRulesPage = lazy(() => import('./pages/RoutingRules')) +const SettingsPage = lazy(() => import('./pages/Settings')) const queryClient = new QueryClient({ defaultOptions: { @@ -74,6 +77,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index e4b905b..fda5dbe 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { DashboardOutlined, GlobalOutlined } from '@ant-design/icons' +import { BranchesOutlined, DashboardOutlined, DatabaseOutlined, GlobalOutlined, SettingOutlined } from '@ant-design/icons' import { Menu, Typography } from 'antd' import { useNavigate, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' @@ -11,6 +11,9 @@ export default function Sidebar() { const items = [ { key: '/dashboard', icon: , label: t('nav.dashboard') }, { key: '/domains', icon: , label: t('nav.domains') }, + { key: '/backends', icon: , label: t('nav.backends') }, + { key: '/routing-rules', icon: , label: t('nav.routing') }, + { key: '/settings', icon: , label: t('nav.settings') }, ] return ( diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index e76e8df..2dbe5f4 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -55,6 +55,45 @@ "delete": "Löschen", "deleteConfirm": "Domain {{name}} wirklich löschen?" }, + "backends": { + "title": "Backends", + "addBackend": "Backend hinzufügen", + "editBackend": "Backend bearbeiten", + "name": "Name", + "scheme": "Schema", + "address": "Adresse", + "port": "Port", + "target": "Ziel", + "healthCheck": "Health-Check-Pfad", + "active": "Aktiv", + "actions": "Aktionen", + "deleteConfirm": "Backend {{name}} wirklich löschen?" + }, + "routing": { + "title": "Routing-Regeln", + "addRule": "Regel hinzufügen", + "editRule": "Regel bearbeiten", + "domain": "Domain", + "pathPrefix": "Pfad-Präfix", + "backend": "Backend", + "priority": "Priorität", + "active": "Aktiv", + "actions": "Aktionen", + "selectDomain": "Domain wählen", + "selectBackend": "Backend wählen", + "deleteConfirm": "Diese Routing-Regel wirklich löschen?" + }, + "settings": { + "title": "Einstellungen", + "intro": "System-Information und Setup-Status. Bearbeitbare Werte folgen in einem späteren Release.", + "systemInfo": "System", + "version": "Version", + "status": "Status", + "setupInfo": "Setup", + "adminEmail": "Admin-E-Mail", + "fqdn": "FQDN", + "setupCompleted": "Setup abgeschlossen" + }, "update": { "available": "Update verfügbar: {{pkg}} {{installed}} → {{available}}", "applyNow": "Jetzt aktualisieren", @@ -67,6 +106,8 @@ "save": "Speichern", "cancel": "Abbrechen", "loading": "Lädt …", - "error": "Fehler" + "error": "Fehler", + "edit": "Bearbeiten", + "delete": "Löschen" } } diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index dbd5ed0..a763e1a 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -55,6 +55,45 @@ "delete": "Delete", "deleteConfirm": "Really delete domain {{name}}?" }, + "backends": { + "title": "Backends", + "addBackend": "Add backend", + "editBackend": "Edit backend", + "name": "Name", + "scheme": "Scheme", + "address": "Address", + "port": "Port", + "target": "Target", + "healthCheck": "Health check path", + "active": "Active", + "actions": "Actions", + "deleteConfirm": "Really delete backend {{name}}?" + }, + "routing": { + "title": "Routing rules", + "addRule": "Add rule", + "editRule": "Edit rule", + "domain": "Domain", + "pathPrefix": "Path prefix", + "backend": "Backend", + "priority": "Priority", + "active": "Active", + "actions": "Actions", + "selectDomain": "Select domain", + "selectBackend": "Select backend", + "deleteConfirm": "Really delete this routing rule?" + }, + "settings": { + "title": "Settings", + "intro": "System information and setup status. Editable values come in a later release.", + "systemInfo": "System", + "version": "Version", + "status": "Status", + "setupInfo": "Setup", + "adminEmail": "Admin email", + "fqdn": "FQDN", + "setupCompleted": "Setup completed" + }, "update": { "available": "Update available: {{pkg}} {{installed}} → {{available}}", "applyNow": "Apply now", @@ -67,6 +106,8 @@ "save": "Save", "cancel": "Cancel", "loading": "Loading …", - "error": "Error" + "error": "Error", + "edit": "Edit", + "delete": "Delete" } } diff --git a/management-ui/src/pages/Backends/index.tsx b/management-ui/src/pages/Backends/index.tsx new file mode 100644 index 0000000..43a1a5d --- /dev/null +++ b/management-ui/src/pages/Backends/index.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react' +import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Typography, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' + +interface Backend { + id: number + name: string + scheme: string + address: string + port: number + health_check_path?: string | null + active: boolean + created_at: string + updated_at: string +} + +interface BackendFormValues { + name: string + scheme: 'http' | 'https' + address: string + port: number + health_check_path?: string + active: boolean +} + +async function listBackends(): Promise { + const r = await apiClient.get('/backends') + if (!isEnvelope(r.data)) return [] + const payload = r.data.data as { backends?: Backend[] } + return payload.backends ?? [] +} + +export default function BackendsPage() { + const { t } = useTranslation() + const qc = useQueryClient() + + const { data, isLoading } = useQuery({ queryKey: ['backends'], queryFn: listBackends }) + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const create = useMutation({ + mutationFn: async (v: BackendFormValues) => { + await apiClient.post('/backends', v) + }, + onSuccess: () => { + message.success(t('common.save')) + setCreating(false) + form.resetFields() + void qc.invalidateQueries({ queryKey: ['backends'] }) + }, + }) + + const update = useMutation({ + mutationFn: async ({ id, v }: { id: number; v: BackendFormValues }) => { + await apiClient.put(`/backends/${id}`, v) + }, + onSuccess: () => { + message.success(t('common.save')) + setEditing(null) + form.resetFields() + void qc.invalidateQueries({ queryKey: ['backends'] }) + }, + }) + + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/backends/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backends'] }) }, + }) + + const columns: ColumnsType = [ + { title: t('backends.name'), dataIndex: 'name', key: 'name' }, + { title: t('backends.scheme'), dataIndex: 'scheme', key: 'scheme' }, + { + title: t('backends.target'), key: 'target', + render: (_, row) => `${row.address}:${row.port}`, + }, + { title: t('backends.healthCheck'), dataIndex: 'health_check_path', key: 'hc', render: (v?: string) => v ?? '—' }, + { title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') }, + { + title: t('backends.actions'), key: 'actions', + render: (_, row) => ( + + + del.mutate(row.id)} + > + + + + ), + }, + ] + + return ( +
+ {t('backends.title')} + + + { setEditing(null); setCreating(false) }} + onOk={() => { void form.submit() }} + confirmLoading={create.isPending || update.isPending} + > +
{ + if (editing) update.mutate({ id: editing.id, v }) + else create.mutate(v) + }} + > + + + + + + + + + + + + + + + + +
+ + ) +} diff --git a/management-ui/src/pages/RoutingRules/index.tsx b/management-ui/src/pages/RoutingRules/index.tsx new file mode 100644 index 0000000..9663ec2 --- /dev/null +++ b/management-ui/src/pages/RoutingRules/index.tsx @@ -0,0 +1,172 @@ +import { useState } from 'react' +import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Typography, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' + +interface RoutingRule { + id: number + domain_id: number + path_prefix: string + backend_id: number + priority: number + active: boolean + created_at: string + updated_at: string +} + +interface RuleFormValues { + domain_id: number + path_prefix: string + backend_id: number + priority: number + active: boolean +} + +interface Domain { id: number; name: string } +interface Backend { id: number; name: string; address: string; port: number } + +async function listRules(): Promise { + const r = await apiClient.get('/routing-rules') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { routing_rules?: RoutingRule[] }).routing_rules ?? [] +} +async function listDomains(): Promise { + const r = await apiClient.get('/domains') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { domains?: Domain[] }).domains ?? [] +} +async function listBackends(): Promise { + const r = await apiClient.get('/backends') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { backends?: Backend[] }).backends ?? [] +} + +export default function RoutingRulesPage() { + const { t } = useTranslation() + const qc = useQueryClient() + + const { data: rules, isLoading } = useQuery({ queryKey: ['routing-rules'], queryFn: listRules }) + const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains }) + const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends }) + + const domainName = (id: number) => domains?.find((d) => d.id === id)?.name ?? `#${id}` + const backendLabel = (id: number) => { + const b = backends?.find((x) => x.id === id) + return b ? `${b.name} (${b.address}:${b.port})` : `#${id}` + } + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const create = useMutation({ + mutationFn: async (v: RuleFormValues) => { await apiClient.post('/routing-rules', v) }, + onSuccess: () => { + message.success(t('common.save')) + setCreating(false) + form.resetFields() + void qc.invalidateQueries({ queryKey: ['routing-rules'] }) + }, + }) + const update = useMutation({ + mutationFn: async ({ id, v }: { id: number; v: RuleFormValues }) => { + await apiClient.put(`/routing-rules/${id}`, v) + }, + onSuccess: () => { + message.success(t('common.save')) + setEditing(null) + form.resetFields() + void qc.invalidateQueries({ queryKey: ['routing-rules'] }) + }, + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/routing-rules/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['routing-rules'] }) }, + }) + + const columns: ColumnsType = [ + { title: t('routing.domain'), dataIndex: 'domain_id', key: 'domain', render: (id: number) => domainName(id) }, + { title: t('routing.pathPrefix'), dataIndex: 'path_prefix', key: 'path' }, + { title: t('routing.backend'), dataIndex: 'backend_id', key: 'backend', render: (id: number) => backendLabel(id) }, + { title: t('routing.priority'), dataIndex: 'priority', key: 'priority' }, + { title: t('routing.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') }, + { + title: t('routing.actions'), key: 'actions', + render: (_, row) => ( + + + del.mutate(row.id)} + > + + + + ), + }, + ] + + return ( +
+ {t('routing.title')} + +
+ { setEditing(null); setCreating(false) }} + onOk={() => { void form.submit() }} + confirmLoading={create.isPending || update.isPending} + > +
{ + if (editing) update.mutate({ id: editing.id, v }) + else create.mutate(v) + }} + > + + + + +