Files
edgeguard-native/management-ui/src/pages/Dashboard/index.tsx
Debian 99982ef9d1 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>
2026-05-24 08:55:22 +02:00

698 lines
31 KiB
TypeScript

import { Alert, Card, Col, Progress, Row, Space, Statistic, Tag, Tooltip, Typography } from 'antd'
import {
ApartmentOutlined, ApiOutlined, BellOutlined, BranchesOutlined, ClusterOutlined,
DashboardOutlined, DatabaseOutlined, FireOutlined, GlobalOutlined,
SafetyCertificateOutlined, ThunderboltOutlined,
} from '@ant-design/icons'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import StatusDot from '../../components/StatusDot'
import UpdateBanner from '../../components/UpdateBanner'
const { Text } = Typography
// useAuditLive verbindet sich mit dem WebSocket-Stream /audit/live.
// Server sendet beim Connect die letzten 50 Einträge (oldest→newest),
// danach jeden neuen INSERT direkt. UI behält das letzte `keep` und
// zeigt newest-first. Reconnect alle 2s bei Drop.
function useAuditLive(keep = 15) {
const [data, setData] = useState<AuditEntry[]>([])
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
const connect = () => {
if (cancelled) return
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${proto}//${window.location.host}/api/v1/audit/live`
let ws: WebSocket
try { ws = new WebSocket(url) } catch { scheduleReconnect(); return }
wsRef.current = ws
ws.onmessage = (ev) => {
try {
const e: AuditEntry = JSON.parse(ev.data as string)
setData((prev) => {
// dedupe by id (Snapshot + live können sich kurz überschneiden)
if (prev.some((p) => p.id === e.id)) return prev
const next = [e, ...prev]
return next.length > keep ? next.slice(0, keep) : next
})
} catch { /* ignore parse fail */ }
}
ws.onclose = () => { if (!cancelled) scheduleReconnect() }
ws.onerror = () => { /* onclose feuert danach */ }
}
const scheduleReconnect = () => {
if (timer) clearTimeout(timer)
timer = setTimeout(connect, 2000)
}
connect()
return () => {
cancelled = true
if (timer) clearTimeout(timer)
if (wsRef.current) wsRef.current.close()
}
}, [keep])
return data
}
// ── Wire shapes ───────────────────────────────────────────────
interface Domain { id: number; name: string; active: boolean; primary_backend_id?: number | null; maintenance_mode: boolean }
interface Backend { id: number; active: boolean; name: string }
interface Iface { id: number; active: boolean; name: string; type: string }
interface FwRule { id: number; enabled: boolean; action: string }
interface FwNAT { id: number; enabled: boolean; kind: string }
interface FwZone { id: number; name: string; builtin: boolean }
interface TLSCert { id: number; common_name: string; not_after?: string }
interface ClusterNode { id: string; fqdn: string; role: string }
interface WGIface { id: number; name: string; mode: string; active: boolean }
interface WGStatusRow {
interface: string
peer_public_key: string
endpoint?: string
last_handshake_unix: number
transfer_rx: number
transfer_tx: number
}
interface ServiceStatus {
label: string
unit: string
active: boolean
state: string
since?: string
}
interface Resources {
load_avg_1: number; load_avg_5: number; load_avg_15: number
mem_total_kb: number; mem_avail_kb: number; mem_used_pct: number
disk_total_gb: number; disk_free_gb: number; disk_used_pct: number
conntrack_count: number; conntrack_max: number
uptime_sec: number; boot_time_unix: number
}
interface AuditEntry {
id: number; actor: string; action: string; subject?: string
detail?: unknown; created_at: string
}
interface HAProxyBackend {
backend: string; server: string; status: string
sessions: number; bytes_in: number; bytes_out: number
req_tot: number; req_rate: number
last_change_sec: number; health?: string
}
// ── Fetchers ──────────────────────────────────────────────────
async function fetchList<T>(url: string, key: string): Promise<T[]> {
try {
const r = await apiClient.get(url)
if (!isEnvelope(r.data)) return []
return ((r.data.data as Record<string, T[]>)[key]) ?? []
} catch { return [] }
}
async function fetchOne<T>(url: string): Promise<T | null> {
try {
const r = await apiClient.get(url)
return isEnvelope(r.data) ? (r.data.data as T) : null
} catch { return null }
}
// ── Helpers ───────────────────────────────────────────────────
function formatBytes(n: number): string {
if (!n) return '0 B'
if (n < 1024) return `${n} B`
const u = ['KiB', 'MiB', 'GiB', 'TiB']
let v = n / 1024, i = 0
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
return `${v.toFixed(1)} ${u[i]}`
}
function relativeTime(unix: number): string {
if (!unix) return 'nie'
const sec = Math.max(0, Math.floor(Date.now() / 1000) - unix)
if (sec < 60) return `vor ${sec}s`
if (sec < 3600) return `vor ${Math.floor(sec / 60)}m`
if (sec < 86400) return `vor ${Math.floor(sec / 3600)}h`
return `vor ${Math.floor(sec / 86400)}d`
}
function formatUptime(sec: number): string {
if (sec < 60) return `${sec}s`
const days = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
const m = Math.floor((sec % 3600) / 60)
if (days > 0) return `${days}d ${h}h`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
function relativeFromIso(iso: string): string {
const t = new Date(iso).getTime()
if (!t) return ''
return relativeTime(Math.floor(t / 1000))
}
// ── Page ──────────────────────────────────────────────────────
interface AlertEvent {
id: number
kind: string
severity: 'info' | 'warning' | 'error' | 'critical'
subject: string
message: string
fired_at: string
}
export default function DashboardPage() {
const { t } = useTranslation()
const health = useQuery({
queryKey: ['system', 'health'],
queryFn: () => fetchOne<{ status: string; version: string }>('/system/health'),
refetchInterval: 30_000,
})
const recentAlerts = useQuery({
queryKey: ['alerts', 'events', 'recent'],
queryFn: () => fetchList<AlertEvent>('/alerts/events?limit=10', 'events'),
refetchInterval: 60_000,
})
const services = useQuery({
queryKey: ['system', 'services'],
queryFn: () => fetchList<ServiceStatus>('/system/services', 'services'),
refetchInterval: 10_000,
})
const resources = useQuery({
queryKey: ['system', 'resources'],
queryFn: () => fetchOne<Resources>('/system/resources'),
refetchInterval: 10_000,
})
const haproxyBackends = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: () => fetchList<HAProxyBackend>('/haproxy/stats', 'backends'),
refetchInterval: 10_000,
})
const auditEntries = useAuditLive(15)
const domains = useQuery({ queryKey: ['domains'], queryFn: () => fetchList<Domain>('/domains', 'domains') })
const backends = useQuery({ queryKey: ['backends'], queryFn: () => fetchList<Backend>('/backends', 'backends') })
const ifaces = useQuery({ queryKey: ['network-interfaces'], queryFn: () => fetchList<Iface>('/network-interfaces', 'interfaces') })
const fwRules = useQuery({ queryKey: ['fw', 'rules'], queryFn: () => fetchList<FwRule>('/firewall/rules', 'rules') })
const fwNAT = useQuery({ queryKey: ['fw', 'nat'], queryFn: () => fetchList<FwNAT>('/firewall/nat-rules', 'nat_rules') })
const fwZones = useQuery({ queryKey: ['fw-zones'], queryFn: () => fetchList<FwZone>('/firewall/zones', 'zones') })
const tlsCerts = useQuery({ queryKey: ['tls-certs'], queryFn: () => fetchList<TLSCert>('/tls-certs', 'tls_certs') })
const cluster = useQuery({ queryKey: ['cluster', 'nodes'], queryFn: () => fetchList<ClusterNode>('/cluster/nodes', 'nodes') })
// Zusätzlich /cluster/status für die Health-Ampel: liefert mode +
// health + drift_found. Refresh-Intervall etwas länger (30s) als die
// anderen Dashboard-Queries — die meisten Werte ändern sich selten.
const clusterStatus = useQuery({
queryKey: ['cluster', 'status'],
queryFn: () => fetchOne<{
mode: 'single-node' | 'cluster'
health: 'ok' | 'degraded' | 'split-brain'
drift_found: boolean
}>('/cluster/status'),
refetchInterval: 30_000,
})
// License-Status für PageHeader-Tag — frische Boxen sehen sofort
// wieviel Trial-Zeit übrig ist. Kein Spam: Anzeige nur wenn
// payload da ist; bei Errors fall silent (Lizenz-Page bleibt
// die Autoritäts-Quelle).
const license = useQuery({
queryKey: ['license', 'status'],
queryFn: () => fetchOne<{
status: string
type?: string
valid?: boolean
valid_until?: string
expires_at?: string
license_key?: string
}>('/license/status'),
refetchInterval: 5 * 60_000,
})
const wgIfaces = useQuery({ queryKey: ['wg', 'interfaces'], queryFn: () => fetchList<WGIface>('/wireguard/interfaces', 'interfaces') })
const wgStatus = useQuery({
queryKey: ['wg', 'status'],
queryFn: () => fetchList<WGStatusRow>('/wireguard/status', 'status'),
refetchInterval: 10_000,
})
// Derived stats (same as before — KPI tiles)
const activeBackends = (backends.data ?? []).filter(b => b.active).length
const activeDomains = (domains.data ?? []).filter(d => d.active).length
const activeIfaces = (ifaces.data ?? []).filter(i => i.active).length
const activeFwRules = (fwRules.data ?? []).filter(r => r.enabled).length
const activeNAT = (fwNAT.data ?? []).filter(r => r.enabled).length
const wgServers = (wgIfaces.data ?? []).filter(i => i.mode === 'server' && i.active).length
const wgClients = (wgIfaces.data ?? []).filter(i => i.mode === 'client' && i.active).length
const wgConnected = (wgStatus.data ?? []).filter(s => s.last_handshake_unix > 0
&& Date.now() / 1000 - s.last_handshake_unix < 180).length
const now = Date.now()
const maintenanceDomains = (domains.data ?? []).filter(d => d.maintenance_mode)
// Backends die komplett DOWN sind (mind. 1 Server mit echtem Check,
// aber kein einziger UP) — liefert friendly Backend-Namen.
const downBackends = (() => {
const stats = haproxyBackends.data ?? []
const bklist = backends.data ?? []
if (!stats.length || !bklist.length) return []
// gruppiere Stat-Zeilen nach HAProxy-Backend-Name (eg_backend_<id>)
const byBackend = new Map<string, typeof stats>()
for (const s of stats) {
const arr = byBackend.get(s.backend) ?? []; arr.push(s)
byBackend.set(s.backend, arr)
}
const down: string[] = []
for (const [haName, servers] of byBackend) {
const hasRealCheck = servers.some(s => s.status !== 'no check')
if (hasRealCheck && !servers.some(s => s.status === 'UP')) {
const m = /^eg_backend_(\d+)$/.exec(haName)
const id = m ? Number(m[1]) : null
const b = id != null ? bklist.find(x => x.id === id) : null
down.push(b?.name ?? haName)
}
}
return down
})()
const certsSoon = (tlsCerts.data ?? []).filter(c => {
if (!c.not_after) return false
const exp = new Date(c.not_after).getTime()
return exp - now < 30 * 86_400_000
})
return (
<div>
<PageHeader
icon={<DashboardOutlined />}
title={t('dashboard.title')}
subtitle={t('dashboard.welcomeHint')}
extra={
<Space>
{/* Compact-Variante: prominenter „Auf Updates prüfen"-Button
im Dashboard-Header (Pattern 1:1 aus mail-gateway
Dashboard/v2/index.tsx). Bypasst den Server-seitigen
5-min-apt-update-Throttle via ?force=1, sodass der
Operator nach einem Publish nicht aufs 30s-Polling
warten muss. Der globale Banner in AppLayout zeigt
das Ergebnis dann sofort an. */}
<UpdateBanner compact />
{license.data && <LicenseChip data={license.data} />}
<Tag color="blue">v{health.data?.version ?? '—'}</Tag>
<StatusDot active={health.data?.status === 'ok'} />
</Space>
}
/>
{/* ── Onboarding-Hinweis für frische Boxen ──────────
Erscheint nur wenn 0 Domains UND 0 Backends — verschwindet
sobald irgendwas konfiguriert ist. Drei klickbare Quick-Links
zu den nächsten typischen Setup-Schritten. */}
{(domains.data?.length ?? 0) === 0 && (backends.data?.length ?? 0) === 0 && (
<Alert
type="info"
showIcon
className="mb-12"
message={t('dashboard.onboardingTitle')}
description={
<Space direction="vertical" size={4}>
<Text>{t('dashboard.onboardingIntro')}</Text>
<ol style={{ margin: '4px 0 0 18px', padding: 0 }}>
<li><Link to="/backends">{t('dashboard.onboardingStep1')}</Link></li>
<li><Link to="/domains">{t('dashboard.onboardingStep2')}</Link></li>
<li><Link to="/ssl">{t('dashboard.onboardingStep3')}</Link></li>
</ol>
</Space>
}
/>
)}
{downBackends.length > 0 && (
<Alert
type="error"
showIcon
className="mb-12"
message={t('dashboard.downBackendsAlert', { count: downBackends.length })}
description={
<Space wrap size={4}>
{downBackends.map(name => <Link key={name} to="/backends">{name}</Link>)}
</Space>
}
/>
)}
{maintenanceDomains.length > 0 && (
<Alert
type="warning"
showIcon
className="mb-12"
message={t('dashboard.maintenanceAlert', { count: maintenanceDomains.length })}
description={
<Space wrap size={4}>
{maintenanceDomains.map(d => <Link key={d.id} to={`/domains/${d.id}`}>{d.name}</Link>)}
</Space>
}
/>
)}
{/* ── KPI tiles (compact strip) ──────────────────── */}
<Row gutter={[12, 12]} className="mb-12">
<KPI icon={<GlobalOutlined />} label={t('dashboard.kpi.domains')} value={activeDomains} total={(domains.data ?? []).length} />
<KPI icon={<DatabaseOutlined />} label={t('dashboard.kpi.backends')} value={activeBackends} total={(backends.data ?? []).length} />
<KPI icon={<ClusterOutlined />} label={t('dashboard.kpi.ifaces')} value={activeIfaces} total={(ifaces.data ?? []).length} />
<KPI icon={<FireOutlined />} label={t('dashboard.kpi.fwRules')} value={activeFwRules} total={(fwRules.data ?? []).length} />
<KPI icon={<BranchesOutlined />} label={t('dashboard.kpi.natRules')} value={activeNAT} total={(fwNAT.data ?? []).length} />
<KPI icon={<ThunderboltOutlined />} label={t('dashboard.kpi.wg')} value={wgConnected} total={wgServers + wgClients} />
</Row>
{/* ── Resources strip (load / mem / disk / conntrack / uptime) ── */}
<Row gutter={[12, 12]} className="mb-12">
<ResourcesCard r={resources.data} />
</Row>
{/* ── Recent Alerts ──────────────────────────────────
Card erscheint nur wenn überhaupt Events da sind, sonst macht
sie auf einer frischen Box visuelles Rauschen. Link zur
vollständigen Alerts-Seite für Filter + Channel-Config. */}
{(recentAlerts.data?.length ?? 0) > 0 && (
<Card
size="small"
className="mb-12"
title={<><BellOutlined /> {t('dashboard.alertsCard.title')}</>}
extra={<Link to="/alerts">{t('dashboard.alertsCard.viewAll')}</Link>}
>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
{(recentAlerts.data ?? []).map(e => (
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag color={
e.severity === 'critical' ? 'red'
: e.severity === 'error' ? 'red'
: e.severity === 'warning' ? 'orange'
: 'blue'
}>{e.severity}</Tag>
<Text strong style={{ flex: '0 0 auto' }}>{e.subject}</Text>
<Text type="secondary" style={{ fontSize: 11, flex: '0 0 auto' }}>
{new Date(e.fired_at).toLocaleString()}
</Text>
<Text type="secondary" ellipsis style={{ flex: '1 1 auto', fontSize: 12 }}>
{e.message}
</Text>
</div>
))}
</Space>
</Card>
)}
{/* ── Service-health-grid ─────────────────────────── */}
<Card size="small" title={<><DashboardOutlined /> {t('dashboard.servicesCard.title')}</>} className="mb-12">
<Row gutter={[8, 8]}>
{(services.data ?? []).map(s => (
<Col key={s.unit} xs={12} sm={8} md={6} lg={3}>
<Tooltip title={s.since ? `${s.state} since ${s.since}` : s.state}>
<div style={{ padding: '8px 10px', border: '1px solid #E2E8F0', borderRadius: 6, background: '#FFF' }}>
<Space size={4} direction="vertical" style={{ width: '100%' }}>
<Text style={{ fontSize: 12, fontWeight: 500 }}>{s.label}</Text>
<StatusDot active={s.active} activeLabel={s.state} inactiveLabel={s.state || 'unknown'} />
</Space>
</div>
</Tooltip>
</Col>
))}
</Row>
</Card>
<Row gutter={[12, 12]} className="mb-12">
{/* ── Recent activity (audit log) ─────────────────── */}
<Col xs={24} lg={12}>
<Card size="small" title={<><ApiOutlined /> {t('dashboard.activityCard.title')}</>} className="h-100">
{auditEntries.length === 0 ? (
<Text type="secondary">{t('dashboard.activityCard.empty')}</Text>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={2}>
{auditEntries.map(e => (
<div key={e.id} style={{ fontSize: 12, color: '#334155', borderBottom: '1px solid #F1F5F9', padding: '4px 0' }}>
<Space size={6} wrap>
<Tag color="blue" style={{ margin: 0, fontSize: 10 }}>{e.action}</Tag>
<Text style={{ fontSize: 12 }}><b>{e.actor}</b></Text>
{e.subject && <Text type="secondary" style={{ fontSize: 11 }}>{e.subject}</Text>}
<Text type="secondary" style={{ fontSize: 11, marginLeft: 'auto' }}>{relativeFromIso(e.created_at)}</Text>
</Space>
</div>
))}
</Space>
)}
</Card>
</Col>
{/* ── HAProxy backend live health ─────────────────── */}
<Col xs={24} lg={12}>
<Card size="small" title={<><DatabaseOutlined /> {t('dashboard.haproxyCard.title')}</>} className="h-100">
{(haproxyBackends.data ?? []).length === 0 ? (
<Text type="secondary">{t('dashboard.haproxyCard.empty')}</Text>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={2}>
{(haproxyBackends.data ?? []).map((b, i) => (
<div key={i} style={{ fontSize: 12, color: '#334155', borderBottom: '1px solid #F1F5F9', padding: '4px 0' }}>
<Space size={6} wrap>
<code style={{ fontSize: 11 }}>{b.backend}/{b.server}</code>
<Tag
color={b.status === 'UP' ? 'green' : b.status === 'no check' ? 'default' : 'red'}
style={{ margin: 0, fontSize: 10 }}
>{b.status}</Tag>
<Text type="secondary" style={{ fontSize: 11 }}>
{b.sessions} sess{b.req_rate > 0 ? ` · ${b.req_rate}/s` : ''} · {formatBytes(b.bytes_in)} {formatBytes(b.bytes_out)}
</Text>
<Text type="secondary" style={{ fontSize: 11, marginLeft: 'auto' }}>{formatUptime(b.last_change_sec)}</Text>
</Space>
</div>
))}
</Space>
)}
</Card>
</Col>
{/* ── WireGuard live ─────────────────────────────── */}
<Col xs={24} lg={12}>
<Card size="small" title={<><ThunderboltOutlined /> {t('dashboard.wgCard.title')}</>} className="h-100">
{(wgIfaces.data ?? []).length === 0 ? (
<Text type="secondary">{t('dashboard.wgCard.empty')}</Text>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={4}>
{(wgIfaces.data ?? []).map(ifc => {
const status = (wgStatus.data ?? []).filter(s => s.interface === ifc.name)
return (
<div key={ifc.id} style={{ borderBottom: '1px solid #F1F5F9', padding: '4px 0' }}>
<Space>
<code>{ifc.name}</code>
<Tag color={ifc.mode === 'server' ? 'blue' : 'purple'}>{ifc.mode}</Tag>
<StatusDot active={ifc.active} />
</Space>
{status.map(s => (
<div key={s.peer_public_key} style={{ fontSize: 12, color: '#64748B', marginTop: 2 }}>
{s.endpoint && <span>{s.endpoint} · </span>}
<span>{relativeTime(s.last_handshake_unix)}</span>
{' · ▼'}{formatBytes(s.transfer_rx)}{' · ▲'}{formatBytes(s.transfer_tx)}
</div>
))}
</div>
)
})}
</Space>
)}
</Card>
</Col>
{/* ── Cluster ─────────────────────────────────────── */}
<Col xs={24} lg={12}>
<Card
size="small"
title={<><ApartmentOutlined /> {t('dashboard.clusterCard.title')}</>}
className="h-100"
extra={clusterStatus.data && (
<Space size={4}>
<Tag color={clusterStatus.data.mode === 'cluster' ? 'blue' : 'default'}>
{clusterStatus.data.mode === 'cluster'
? t('dashboard.clusterCard.modeCluster')
: t('dashboard.clusterCard.modeSingle')}
</Tag>
<Tag color={
clusterStatus.data.health === 'ok' ? 'green'
: clusterStatus.data.health === 'degraded' ? 'orange'
: 'red'
}>
{t(`dashboard.clusterCard.health.${clusterStatus.data.health}`)}
</Tag>
</Space>
)}
>
<Statistic title={t('dashboard.clusterCard.nodes')} value={(cluster.data ?? []).length} />
{clusterStatus.data?.drift_found && (
<Tag color="red" style={{ marginTop: 8 }}>
{t('dashboard.clusterCard.drift')}
</Tag>
)}
<Space direction="vertical" style={{ marginTop: 6, width: '100%' }} size={2}>
{(cluster.data ?? []).map(n => (
<div key={n.id} style={{ fontSize: 12, color: '#334155' }}>
<code>{n.fqdn}</code> <Tag color={n.role === 'primary' ? 'green' : 'default'}>{n.role}</Tag>
</div>
))}
</Space>
</Card>
</Col>
{/* ── Firewall ────────────────────────────────────── */}
<Col xs={24} md={12} xl={8}>
<Card size="small" title={<><FireOutlined /> {t('dashboard.firewallCard.title')}</>} className="h-100">
<Statistic title={t('dashboard.firewallCard.zones')} value={(fwZones.data ?? []).length} />
<Space wrap style={{ marginTop: 6 }}>
{(fwZones.data ?? []).map(z => (
<Tag key={z.id} color={z.builtin ? 'blue' : 'gold'}>{z.name.toUpperCase()}</Tag>
))}
</Space>
<div style={{ marginTop: 12, fontSize: 12, color: '#64748B' }}>
{t('dashboard.firewallCard.activeRules', { rules: activeFwRules, nat: activeNAT })}
</div>
</Card>
</Col>
{/* ── SSL ─────────────────────────────────────────── */}
<Col xs={24} md={12} xl={8}>
<Card size="small" title={<><SafetyCertificateOutlined /> {t('dashboard.sslCard.title')}</>} className="h-100">
<Statistic title={t('dashboard.sslCard.total')} value={(tlsCerts.data ?? []).length} />
{certsSoon.length > 0 ? (
<Alert
style={{ marginTop: 8 }}
type="warning"
showIcon
message={t('dashboard.sslCard.expiringSoon', { count: certsSoon.length })}
/>
) : (
<div style={{ marginTop: 8, fontSize: 12, color: '#64748B' }}>
{t('dashboard.sslCard.allFresh')}
</div>
)}
</Card>
</Col>
{/* ── Routing summary ─────────────────────────────── */}
<Col xs={24} md={12} xl={8}>
<Card size="small" title={<><GlobalOutlined /> {t('dashboard.routingCard.title')}</>} className="h-100">
<Row gutter={8}>
<Col span={12}><Statistic title={t('dashboard.routingCard.domains')} value={(domains.data ?? []).length} /></Col>
<Col span={12}><Statistic title={t('dashboard.routingCard.backends')} value={(backends.data ?? []).length} /></Col>
</Row>
<div style={{ marginTop: 8, fontSize: 12, color: '#64748B' }}>
{t('dashboard.routingCard.attached', {
count: (domains.data ?? []).filter(d => d.primary_backend_id).length,
total: (domains.data ?? []).length,
})}
</div>
</Card>
</Col>
</Row>
</div>
)
}
// ── Sub-components ────────────────────────────────────────────
interface KPIProps { icon: React.ReactNode; label: string; value: number; total?: number }
function KPI({ icon, label, value, total }: KPIProps) {
return (
<Col xs={12} sm={8} md={8} lg={4}>
<Card size="small">
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
<span style={{ marginRight: 6, color: '#0EA5E9' }}>{icon}</span>{label}
</Text>
<div style={{ fontSize: 22, fontWeight: 600, color: '#0F172A' }}>
{value}{total !== undefined && total !== value && <span style={{ fontSize: 13, color: '#94A3B8', fontWeight: 400 }}> / {total}</span>}
</div>
</Space>
</Card>
</Col>
)
}
function ResourcesCard({ r }: { r?: Resources | null }) {
const { t } = useTranslation()
if (!r) return null
const memUsedGB = ((r.mem_total_kb - r.mem_avail_kb) / 1024 / 1024).toFixed(1)
const memTotalGB = (r.mem_total_kb / 1024 / 1024).toFixed(1)
const ctPct = r.conntrack_max > 0 ? (r.conntrack_count * 100 / r.conntrack_max) : 0
return (
<Col span={24}>
<Card size="small">
<Row gutter={[16, 8]}>
<Col xs={12} sm={6} md={4}>
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>{t('dashboard.resCard.load')}</Text>
<div style={{ fontSize: 18, fontWeight: 600 }}>
{r.load_avg_1.toFixed(2)} / {r.load_avg_5.toFixed(2)} / {r.load_avg_15.toFixed(2)}
</div>
</Col>
<Col xs={12} sm={6} md={6}>
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>
{t('dashboard.resCard.memory')} ({memUsedGB} / {memTotalGB} GB)
</Text>
<Progress percent={Math.round(r.mem_used_pct)} size="small" status={r.mem_used_pct > 90 ? 'exception' : 'normal'} />
</Col>
<Col xs={12} sm={6} md={6}>
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>
{t('dashboard.resCard.disk')} ({r.disk_free_gb.toFixed(1)} GB {t('dashboard.resCard.free')} / {r.disk_total_gb.toFixed(1)} GB)
</Text>
<Progress percent={Math.round(r.disk_used_pct)} size="small" status={r.disk_used_pct > 90 ? 'exception' : 'normal'} />
</Col>
<Col xs={12} sm={6} md={4}>
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>
{t('dashboard.resCard.conntrack')} ({r.conntrack_count} / {r.conntrack_max})
</Text>
<Progress percent={Math.round(ctPct)} size="small" status={ctPct > 80 ? 'exception' : 'normal'} />
</Col>
<Col xs={12} sm={6} md={4}>
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>{t('dashboard.resCard.uptime')}</Text>
<div style={{ fontSize: 18, fontWeight: 600 }}>{formatUptime(r.uptime_sec)}</div>
</Col>
</Row>
</Card>
</Col>
)
}
// LicenseChip rendert die License-Info als kompaktes Tag im PageHeader.
// Farb-Logik:
// * Trial < 7 Tage: rot (Eskalation)
// * Trial 7-14 Tage: orange (Warnung)
// * Trial > 14 Tage: blau (informativ)
// * Aktive Lizenz (kein Trial): grün
// * Expired/Invalid: rot
// Bei unklarem status → kein Tag (silent fallback, /license-Page hat Detail).
function LicenseChip({ data }: { data: {
status: string
type?: string
valid?: boolean
valid_until?: string
expires_at?: string
license_key?: string
}}) {
const exp = data.valid_until ?? data.expires_at
const days = exp ? Math.ceil((new Date(exp).getTime() - Date.now()) / 86_400_000) : null
const isTrial = data.type === 'trial' || (!data.license_key && data.status === 'active')
if (data.status === 'expired' || data.status === 'invalid' || data.valid === false) {
return <Tag color="red">{data.status}</Tag>
}
if (isTrial) {
if (days != null && days <= 7) return <Tag color="red">Trial · {days}d</Tag>
if (days != null && days <= 14) return <Tag color="orange">Trial · {days}d</Tag>
return <Tag color="blue">{days != null ? `Trial · ${days}d` : 'Trial'}</Tag>
}
if (data.status === 'active') {
return <Tag color="green">License OK</Tag>
}
return null
}