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, useMemo, 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([]) const wsRef = useRef(null) useEffect(() => { let cancelled = false let timer: ReturnType | 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 num_cpus?: 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 } interface HAProxyFrontend { name: string; sessions: number; max_sess: number bytes_in: number; bytes_out: number req_tot: number; req_rate: number } // ── Fetchers ────────────────────────────────────────────────── async function fetchList(url: string, key: string): Promise { try { const r = await apiClient.get(url) if (!isEnvelope(r.data)) return [] return ((r.data.data as Record)[key]) ?? [] } catch { return [] } } async function fetchOne(url: string): Promise { 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, t: (k: string, v?: Record) => string): string { if (!unix) return t('common.relTime.never') const sec = Math.max(0, Math.floor(Date.now() / 1000) - unix) if (sec < 60) return t('common.relTime.Xs', { n: sec }) if (sec < 3600) return t('common.relTime.Xm', { n: Math.floor(sec / 60) }) if (sec < 86400) return t('common.relTime.Xh', { n: Math.floor(sec / 3600) }) return t('common.relTime.Xd', { n: Math.floor(sec / 86400) }) } function haCheckHint(code: string): string { const hints: Record = { L4CON: 'TCP connection refused — server port closed or firewall blocking', L4TOUT: 'TCP connection timeout — server unreachable (routing/firewall/server down)', L6CON: 'SSL/TLS handshake failed — certificate or ALPN mismatch', L6TOUT: 'SSL/TLS timeout', L6RSP: 'SSL/TLS protocol error', L7STS: 'HTTP check: unexpected status code (health-check path returns non-2xx)', L7RSP: 'HTTP check: unexpected response from server', L7TOUT: 'HTTP check: timeout waiting for response', SOCKERR: 'Socket error — OS-level problem (EAGAIN, ECONNRESET …)', INI: 'Check not yet performed since last reload', } return hints[code] ?? code } 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, t: (k: string, v?: Record) => string): string { const ts = new Date(iso).getTime() if (!ts) return '' return relativeTime(Math.floor(ts / 1000), t) } // ── 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('/alerts/events?limit=10', 'events'), refetchInterval: 60_000, }) const services = useQuery({ queryKey: ['system', 'services'], queryFn: () => fetchList('/system/services', 'services'), refetchInterval: 10_000, }) const resources = useQuery({ queryKey: ['system', 'resources'], queryFn: () => fetchOne('/system/resources'), refetchInterval: 10_000, }) const haproxyStats = useQuery({ queryKey: ['haproxy', 'stats'], queryFn: async () => { try { const r = await apiClient.get('/haproxy/stats') if (!isEnvelope(r.data)) return { backends: [] as HAProxyBackend[], frontends: [] as HAProxyFrontend[] } const d = r.data.data as { backends?: HAProxyBackend[]; frontends?: HAProxyFrontend[]; error?: string } return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error } } catch { return { backends: [] as HAProxyBackend[], frontends: [] as HAProxyFrontend[], error: undefined } } }, refetchInterval: 10_000, }) const haproxyBackends = { data: haproxyStats.data?.backends } const auditEntries = useAuditLive(15) const domains = useQuery({ queryKey: ['domains'], queryFn: () => fetchList('/domains', 'domains') }) const backends = useQuery({ queryKey: ['backends'], queryFn: () => fetchList('/backends', 'backends') }) const ifaces = useQuery({ queryKey: ['network-interfaces'], queryFn: () => fetchList('/network-interfaces', 'interfaces') }) const fwRules = useQuery({ queryKey: ['fw', 'rules'], queryFn: () => fetchList('/firewall/rules', 'rules') }) const fwNAT = useQuery({ queryKey: ['fw', 'nat'], queryFn: () => fetchList('/firewall/nat-rules', 'nat_rules') }) const fwZones = useQuery({ queryKey: ['fw-zones'], queryFn: () => fetchList('/firewall/zones', 'zones') }) const tlsCerts = useQuery({ queryKey: ['tls-certs'], queryFn: () => fetchList('/tls-certs', 'tls_certs') }) const cluster = useQuery({ queryKey: ['cluster', 'nodes'], queryFn: () => fetchList('/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('/wireguard/interfaces', 'interfaces') }) const wgStatus = useQuery({ queryKey: ['wg', 'status'], queryFn: () => fetchList('/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) // HAProxy frontend technical name → display label. const HA_FRONTEND_LABELS: Record = { public_http: 'HTTP In', public_https: 'HTTPS In', mgmt_https: 'Management', } // eg_backend_ → friendly name lookup for HAProxy stats display. const backendIdToName = useMemo(() => { const m = new Map() for (const b of backends.data ?? []) m.set(b.id, b.name) return m }, [backends.data]) const resolveHAName = (haName: string): string => { const match = /^eg_backend_(\d+)$/.exec(haName) const id = match ? Number(match[1]) : null return id != null ? (backendIdToName.get(id) ?? haName) : haName } // 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_) const byBackend = new Map() 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 certsExpired = (tlsCerts.data ?? []).filter(c => { if (!c.not_after) return false return new Date(c.not_after).getTime() < now }) const certsSoon = (tlsCerts.data ?? []).filter(c => { if (!c.not_after) return false const exp = new Date(c.not_after).getTime() return exp >= now && exp - now < 30 * 86_400_000 }) return (
} title={t('dashboard.title')} subtitle={t('dashboard.welcomeHint')} extra={ {/* 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. */} {license.data && } v{health.data?.version ?? '—'} } /> {/* ── 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 && ( {t('dashboard.onboardingIntro')}
  1. {t('dashboard.onboardingStep1')}
  2. {t('dashboard.onboardingStep2')}
  3. {t('dashboard.onboardingStep3')}
} /> )} {downBackends.length > 0 && ( {downBackends.map(name => {name})} } /> )} {maintenanceDomains.length > 0 && ( {maintenanceDomains.map(d => {d.name})} } /> )} {/* ── KPI tiles (compact strip) ──────────────────── */} } label={t('dashboard.kpi.domains')} value={activeDomains} total={(domains.data ?? []).length} /> } label={t('dashboard.kpi.backends')} value={activeBackends} total={(backends.data ?? []).length} /> } label={t('dashboard.kpi.ifaces')} value={activeIfaces} total={(ifaces.data ?? []).length} /> } label={t('dashboard.kpi.fwRules')} value={activeFwRules} total={(fwRules.data ?? []).length} /> } label={t('dashboard.kpi.natRules')} value={activeNAT} total={(fwNAT.data ?? []).length} /> } label={t('dashboard.kpi.wg')} value={wgConnected} total={wgServers + wgClients} /> {/* ── Resources strip (load / mem / disk / conntrack / uptime) ── */} {/* ── 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 && ( {t('dashboard.alertsCard.title')}} extra={{t('dashboard.alertsCard.viewAll')}} > {(recentAlerts.data ?? []).map(e => (
{e.severity} {e.subject} {new Date(e.fired_at).toLocaleString()} {e.message}
))}
)} {/* ── Service-health-grid ─────────────────────────── */} {t('dashboard.servicesCard.title')}} className="mb-12"> {(services.data ?? []).map(s => (
{s.label}
))}
{/* ── Recent activity (audit log) ─────────────────── */} {t('dashboard.activityCard.title')}} className="h-100"> {auditEntries.length === 0 ? ( {t('dashboard.activityCard.empty')} ) : ( {auditEntries.map(e => (
{e.action} {e.actor} {e.subject && {e.subject}} {relativeFromIso(e.created_at, t)}
))}
)}
{/* ── HAProxy backend live health ─────────────────── */} {t('dashboard.haproxyCard.title')}} className="h-100"> {(haproxyStats.data?.frontends ?? []).length > 0 && (
{t('dashboard.haproxyCard.frontends')} {(haproxyStats.data?.frontends ?? []).map((f) => (
{HA_FRONTEND_LABELS[f.name] ?? f.name} {f.sessions} sess {f.req_rate > 0 ? ` · ${f.req_rate}/s` : ''} {` · ↓${formatBytes(f.bytes_in)} ↑${formatBytes(f.bytes_out)}`}
))}
)} {(haproxyBackends.data ?? []).length === 0 ? ( haproxyStats.data?.error ? ( ⚠ HAProxy socket: {haproxyStats.data.error} ) : ( {t('dashboard.haproxyCard.empty')} ) ) : ( {(haproxyBackends.data ?? []).map((b, i) => (
{resolveHAName(b.backend)}/{b.server} {b.status} {b.health && b.health !== 'L7OK' && b.health !== 'L4OK' && ( {b.health} )} {b.sessions} sess{b.req_rate > 0 ? ` · ${b.req_rate}/s` : ''} · ↓{formatBytes(b.bytes_in)} ↑{formatBytes(b.bytes_out)} {formatUptime(b.last_change_sec)}
))}
)}
{/* ── WireGuard live ─────────────────────────────── */} {t('dashboard.wgCard.title')}} className="h-100"> {(wgIfaces.data ?? []).length === 0 ? ( {t('dashboard.wgCard.empty')} ) : ( {(wgIfaces.data ?? []).map(ifc => { const status = (wgStatus.data ?? []).filter(s => s.interface === ifc.name) const nowSec = Date.now() / 1000 const onlineCount = status.filter(s => s.last_handshake_unix > 0 && nowSec - s.last_handshake_unix < 180).length const totalRx = status.reduce((acc, s) => acc + s.transfer_rx, 0) const totalTx = status.reduce((acc, s) => acc + s.transfer_tx, 0) return (
{ifc.name} {ifc.mode} {ifc.mode === 'server' ? ( status.length > 0 && (
{t('dashboard.wgCard.peersOnline', { online: onlineCount, total: status.length })} {' · ▼'}{formatBytes(totalRx)}{' ▲'}{formatBytes(totalTx)}
) ) : ( status.map(s => (
{s.endpoint && {s.endpoint} · } {relativeTime(s.last_handshake_unix, t)} {' · ▼'}{formatBytes(s.transfer_rx)}{' ▲'}{formatBytes(s.transfer_tx)}
)) )}
) })}
)}
{/* ── Cluster ─────────────────────────────────────── */} {t('dashboard.clusterCard.title')}} className="h-100" extra={clusterStatus.data && ( {clusterStatus.data.mode === 'cluster' ? t('dashboard.clusterCard.modeCluster') : t('dashboard.clusterCard.modeSingle')} {t(`dashboard.clusterCard.health.${clusterStatus.data.health}`)} )} > {clusterStatus.data?.drift_found && ( {t('dashboard.clusterCard.drift')} )} {(cluster.data ?? []).map(n => (
{n.fqdn} {n.role}
))}
{/* ── Firewall ────────────────────────────────────── */} {t('dashboard.firewallCard.title')}} className="h-100"> {(fwZones.data ?? []).map(z => ( {z.name.toUpperCase()} ))}
{t('dashboard.firewallCard.activeRules', { rules: activeFwRules, nat: activeNAT })}
{/* ── SSL ─────────────────────────────────────────── */} {t('dashboard.sslCard.title')}} className="h-100"> {certsExpired.length > 0 && ( )} {certsSoon.length > 0 && ( )} {certsExpired.length === 0 && certsSoon.length === 0 && (
{t('dashboard.sslCard.allFresh')}
)}
{/* ── Routing summary ─────────────────────────────── */} {t('dashboard.routingCard.title')}} className="h-100">
{t('dashboard.routingCard.attached', { count: (domains.data ?? []).filter(d => d.primary_backend_id).length, total: (domains.data ?? []).length, })}
) } // ── Sub-components ──────────────────────────────────────────── interface KPIProps { icon: React.ReactNode; label: string; value: number; total?: number } function KPI({ icon, label, value, total }: KPIProps) { return ( {icon}{label}
{value}{total !== undefined && total !== value && / {total}}
) } 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 ( {t('dashboard.resCard.load')} {r.num_cpus != null && ({r.num_cpus} CPU)}
{r.load_avg_1.toFixed(2)} / {r.load_avg_5.toFixed(2)} / {r.load_avg_15.toFixed(2)}
{t('dashboard.resCard.memory')} ({memUsedGB} / {memTotalGB} GB) 90 ? 'exception' : 'normal'} /> {t('dashboard.resCard.disk')} ({r.disk_free_gb.toFixed(1)} GB {t('dashboard.resCard.free')} / {r.disk_total_gb.toFixed(1)} GB) 90 ? 'exception' : 'normal'} /> {t('dashboard.resCard.conntrack')} ({r.conntrack_count} / {r.conntrack_max}) 80 ? 'exception' : 'normal'} /> {t('dashboard.resCard.uptime')}
{formatUptime(r.uptime_sec)}
) } // 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 { t } = useTranslation() 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 {data.status} } if (isTrial) { const trialLabel = days != null ? t('dashboard.licenseTrialDays', { days }) : t('dashboard.licenseTrial') if (days != null && days <= 7) return {trialLabel} if (days != null && days <= 14) return {trialLabel} return {trialLabel} } if (data.status === 'active') { return {t('dashboard.licenseOk')} } return null }