feat(ui): Quick-Toggles, Config-Preview, Dashboard-Alerts, Domain-Detail-Health

Quick-Toggle-Switches (kein Modal nötig) für: Backends, Backend-Server,
DNS-Zonen, DNS-Records, Domains (active), Firewall-Rules, NAT-Rules,
Forward-Proxy ACLs, Routing-Rules.

Dashboard: Alert-Banner für komplett ausgefallene Backends (HAProxy-Stats)
und Domains im Maintenance-Mode.

Domain-Detail: HAProxy-Live-Health-Badge (15s Polling), TLS-Cert
ausstellen/erneuern direkt aus dem Detail, Routing-Rules-Panel inline.

Config-Preview (Settings): alle 4 Generatoren (haproxy, nftables, squid,
unbound) rendern via RenderToString ohne Disk-Write — GET /system/config-preview.

ActionButtons: Viewer-Rolle blendet Delete aus (RBAC-Ergänzung).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-24 08:55:22 +02:00
parent 386972366b
commit 99982ef9d1
23 changed files with 755 additions and 63 deletions

View File

@@ -38,7 +38,15 @@ interface ServerFormValues {
name: string; address: string; port: number
weight: number; backup: boolean; active: boolean
}
interface DomainFull { id: number; name: string; primary_backend_id?: number | null }
interface DomainFull {
id: number; name: string; active: boolean
primary_backend_id?: number | null
http_to_https: boolean; hsts_enabled: boolean
hsts_max_age: number; hsts_subdomains: boolean; hsts_preload: boolean
maintenance_mode: boolean; maintenance_message?: string | null
www_redirect: string; rate_limit_rps: number
max_body_kb: number; disable_h3: boolean; notes?: string | null
}
async function getBackend(id: number): Promise<Backend | null> {
const r = await apiClient.get(`/backends/${id}`)
@@ -172,9 +180,9 @@ export default function BackendDetailPage() {
<Form.Item label={t('backends.lbAlgo')} name="lb_algorithm" rules={[{ required: true }]}
extra={t('backends.lbAlgoHint')}>
<Select options={[
{ value: 'roundrobin', label: 'roundrobin — gleichmäßige Verteilung' },
{ value: 'leastconn', label: 'leastconn — wenigste aktive Verbindungen' },
{ value: 'source', label: 'source — sticky per Source-IP-Hash' },
{ value: 'roundrobin', label: 'roundrobin' },
{ value: 'leastconn', label: 'leastconn' },
{ value: 'source', label: 'source' },
]} />
</Form.Item>
<Form.Item label={t('backends.healthCheck')} name="health_check_path"
@@ -200,7 +208,7 @@ export default function BackendDetailPage() {
options={(domains ?? []).map(d => ({
value: d.id,
label: d.primary_backend_id && d.primary_backend_id !== backendID
? `${d.name} (derzeit: #${d.primary_backend_id})`
? `${d.name} (${t('backends.attachedToOther', { id: d.primary_backend_id })})`
: d.name,
}))}
/>

View File

@@ -12,7 +12,6 @@ import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
import apiClient, { isEnvelope } from '../../api/client'
@@ -25,6 +24,7 @@ interface Backend {
health_check_path?: string | null
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
websocket: boolean
force_http1: boolean
active: boolean
created_at: string
updated_at: string
@@ -79,6 +79,15 @@ interface DomainFull {
primary_backend_id?: number | null
http_to_https: boolean
hsts_enabled: boolean
hsts_max_age: number
hsts_subdomains: boolean
hsts_preload: boolean
maintenance_mode: boolean
maintenance_message?: string | null
www_redirect: string
rate_limit_rps: number
max_body_kb: number
disable_h3: boolean
notes?: string | null
}
async function listDomains(): Promise<DomainFull[]> {
@@ -171,20 +180,12 @@ export default function BackendsPage() {
for (const id of adds) {
const d = all.find(x => x.id === id)
if (!d) continue
puts.push(apiClient.put(`/domains/${id}`, {
name: d.name, active: d.active,
http_to_https: d.http_to_https, hsts_enabled: d.hsts_enabled,
notes: d.notes ?? '', primary_backend_id: backendID,
}))
puts.push(apiClient.put(`/domains/${id}`, { ...d, primary_backend_id: backendID }))
}
for (const id of removes) {
const d = all.find(x => x.id === id)
if (!d) continue
puts.push(apiClient.put(`/domains/${id}`, {
name: d.name, active: d.active,
http_to_https: d.http_to_https, hsts_enabled: d.hsts_enabled,
notes: d.notes ?? '', primary_backend_id: null,
}))
puts.push(apiClient.put(`/domains/${id}`, { ...d, primary_backend_id: null }))
}
if (puts.length > 0) await Promise.all(puts)
}
@@ -212,6 +213,14 @@ export default function BackendsPage() {
mutationFn: async (id: number) => { await apiClient.delete(`/backends/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backends'] }) },
})
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: Backend; checked: boolean }) => {
const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row
await apiClient.put(`/backends/${id}`, { ...body, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backends'] }) },
onError: (e: Error) => message.error(e.message),
})
const columns: ColumnsType<Backend> = [
{ title: t('backends.name'), dataIndex: 'name', key: 'name' },
@@ -260,7 +269,17 @@ export default function BackendsPage() {
)
},
},
{ title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
{
title: t('backends.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean, row: Backend) => (
<Switch
size="small"
checked={v}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/>
),
},
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
@@ -335,9 +354,9 @@ export default function BackendsPage() {
<Form.Item label={t('backends.lbAlgo')} name="lb_algorithm" rules={[{ required: true }]}
extra={t('backends.lbAlgoHint')}>
<Select options={[
{ value: 'roundrobin', label: 'roundrobin — gleichmäßige Verteilung' },
{ value: 'leastconn', label: 'leastconn — wenigste aktive Verbindungen' },
{ value: 'source', label: 'source — sticky per Source-IP-Hash' },
{ value: 'roundrobin', label: 'roundrobin' },
{ value: 'leastconn', label: 'leastconn' },
{ value: 'source', label: 'source' },
]} />
</Form.Item>
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
@@ -397,6 +416,14 @@ function ServerPanel({ backendID }: { backendID: number }) {
},
onError: (e: Error) => message.error(e.message),
})
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: BackendServer; checked: boolean }) => {
const { id: _id, ...body } = row
await apiClient.put(`/backend-servers/${id}`, { ...body, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<BackendServer> = [
{ title: t('backends.server.name'), dataIndex: 'name' },
@@ -407,8 +434,17 @@ function ServerPanel({ backendID }: { backendID: number }) {
{ title: t('backends.server.weight'), dataIndex: 'weight', width: 80 },
{ title: t('backends.server.backup'), dataIndex: 'backup',
render: (v: boolean) => v ? <Tag color="purple">Backup</Tag> : '—', width: 100 },
{ title: t('backends.active'), dataIndex: 'active',
render: (v: boolean) => <StatusDot active={v} />, width: 80 },
{
title: t('backends.active'), dataIndex: 'active', width: 80,
render: (v: boolean, row: BackendServer) => (
<Switch
size="small"
checked={v}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/>
),
},
{
title: t('common.actions'), key: 'a', width: 160,
render: (_, r) => (