import { Alert, Card, Col, Progress, Row, Space, Statistic, Tag, Tooltip, Typography } from 'antd' import { ApartmentOutlined, ApiOutlined, BellOutlined, BranchesOutlined, ClusterOutlined, DashboardOutlined, DatabaseOutlined, FireOutlined, GlobalOutlined, NodeIndexOutlined, 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 // ── Live audit stream ───────────────────────────────────────── // Server sends the last 50 entries on connect, then each new INSERT. // Reconnects every 2s on 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) => { 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 */ } } ws.onclose = () => { if (!cancelled) scheduleReconnect() } ws.onerror = () => { /* onclose fires next */ } } 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 } interface VIPEntry { address: string; prefix: number; device: string; active: boolean } interface VIPStatus { vrrp_state: string; keepalived_active: boolean; vips: VIPEntry[] } interface AlertEvent { id: number kind: string severity: 'info' | 'warning' | 'error' | 'critical' subject: string message: string fired_at: string } // ── 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', L7RSP: 'HTTP check: unexpected response', L7TOUT: 'HTTP check: timeout', SOCKERR: 'Socket error (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) } const HA_FRONTEND_LABELS: Record = { public_http: 'HTTP In', public_https: 'HTTPS In', mgmt_https: 'Management', } // ── Page ────────────────────────────────────────────────────── 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&open=true', '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 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') }) 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, }) 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, }) const vipStatus = useQuery({ queryKey: ['system', 'vip-status'], queryFn: () => fetchOne('/system/vip-status'), refetchInterval: 10_000, }) // ── Derived stats ── 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) const backendMap = new Map() for (const b of backends.data ?? []) backendMap.set(b.id, b.name) const resolveHAName = (n: string): string => { const m = /^eg_backend_(\d+)$/.exec(n) const id = m ? Number(m[1]) : null return id != null ? (backendMap.get(id) ?? n) : n } const downBackends = (() => { const stats = haproxyStats.data?.backends ?? [] const bklist = backends.data ?? [] if (!stats.length || !bklist.length) return [] 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) { if (servers.some(s => s.status !== 'no check') && !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 })() // Auf dem keepalived-BACKUP-Node erreicht die lokale HAProxy die Backend- // Subnetze nicht (die VLAN-Gateway-VIPs liegen beim Master) → sie sieht // ALLE Backends als down. Das ist strukturell erwartet, kein Ausfall: // der Master bedient den Traffic. Deshalb den roten Down-Alarm auf dem // Standby durch einen ruhigen Hinweis ersetzen. const isBackup = vipStatus.data?.vrrp_state === 'BACKUP' const alerts = recentAlerts.data ?? [] const nCritical = alerts.filter(e => e.severity === 'critical' || e.severity === 'error').length const nWarning = alerts.filter(e => e.severity === 'warning').length return (
} title={t('dashboard.title')} subtitle={t('dashboard.welcomeHint')} extra={ {license.data && } v{health.data?.version ?? '—'} } /> {/* ─ Onboarding ─────────────────────────────────────── */} {(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')}
} /> )} {/* ─ Alert bar (compact) ────────────────────────────── */} {alerts.length > 0 && ( 0 ? 'error' : 'warning'} showIcon icon={} className="mb-12" message={ {t('dashboard.alertsCard.title')} {nCritical > 0 && nWarning > 0 ? t('dashboard.alertsCard.summary', { critical: nCritical, warning: nWarning }) : nCritical > 0 ? t('dashboard.alertsCard.summaryCritical', { critical: nCritical }) : t('dashboard.alertsCard.summaryWarning', { warning: nWarning }) } } action={{t('dashboard.alertsCard.viewAll')} →} /> )} {/* ─ Operational alerts ─────────────────────────────── */} {downBackends.length > 0 && !isBackup && ( {downBackends.map(n => {n})}} /> )} {downBackends.length > 0 && isBackup && ( )} {maintenanceDomains.length > 0 && ( {maintenanceDomains.map(d => {d.name})}} /> )} {/* ─ KPI 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 ────────────────────────────────── */} {/* ─ Services health bar ────────────────────────────── */} {(services.data?.length ?? 0) > 0 && ( )} {/* ─ HA/Cluster row ─────────────────────────────────── */} {/* ─ Traffic row ────────────────────────────────────── */} {/* ─ Infrastructure row ─────────────────────────────── */} {/* ─ Routing + Activity row ─────────────────────────── */}
) } // ── Sub-components ──────────────────────────────────────────── // ── KPI tile ───────────────────────────────────────────────── 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}}
) } // ── Resources strip ─────────────────────────────────────────── 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)}
) } // ── Services health bar ─────────────────────────────────────── const SVC_STATE_COLOR: Record = { active: { bg: '#F0FDF4', border: '#BBF7D0', text: '#166534' }, failed: { bg: '#FEF2F2', border: '#FECACA', text: '#991B1B' }, 'kernel-loaded':{ bg: '#F0FDF4', border: '#BBF7D0', text: '#166534' }, } const SVC_DEFAULT_COLORS = { bg: '#F8FAFC', border: '#E2E8F0', text: '#64748B' } function ServicesBar({ services }: { services: ServiceStatus[] }) { const { t } = useTranslation() return (
{t('dashboard.servicesCard.title')} {services.map(s => { const c = SVC_STATE_COLOR[s.state] ?? SVC_DEFAULT_COLORS return ( {s.label} ) })}
) } // ── VIP / VRRP card ─────────────────────────────────────────── const VRRP_STATE_COLOR: Record = { MASTER: 'green', BACKUP: 'blue', FAULT: 'red', UNKNOWN: 'default', } function VIPCard({ data }: { data?: VIPStatus | null }) { const { t } = useTranslation() const state = data?.vrrp_state ?? 'UNKNOWN' const stateLabel = t(`dashboard.vipCard.state.${state}`) const stateColor = VRRP_STATE_COLOR[state] ?? 'default' return ( {t('dashboard.vipCard.title')} } extra={ {data && ( {stateLabel} )} {data && !data.keepalived_active && ( {t('dashboard.vipCard.keepalivedInactive')} )} } > {!data ? ( ) : data.vips.length === 0 ? ( {t('dashboard.vipCard.noVips')} ) : ( {data.vips.map((v) => (
{v.address}/{v.prefix} {v.active ? 'active' : 'standby'} {v.device && ( dev {v.device} )}
))}
)}
) } // ── Cluster card ────────────────────────────────────────────── interface ClusterStatusCardProps { nodes: ClusterNode[] status: { mode: string; health: string; drift_found: boolean } | null } function ClusterStatusCard({ nodes, status }: ClusterStatusCardProps) { const { t } = useTranslation() return ( {t('dashboard.clusterCard.title')}} extra={status && ( {status.mode === 'cluster' ? t('dashboard.clusterCard.modeCluster') : t('dashboard.clusterCard.modeSingle')} {t(`dashboard.clusterCard.health.${status.health}`)} )} > {status?.drift_found && ( {t('dashboard.clusterCard.drift')} )} {nodes.map(n => (
{n.fqdn} {/* ha_nodes.role ist die DB-/Cluster-Rolle (wohin Schreibzugriffe gehen) und wandert bewusst NICHT mit der VIP — sie aendert sich nur durch `edgeguard-ctl promote`. Ein nacktes "primary" hier las sich neben der VIP-Karte ("BACKUP") wie ein Widerspruch, deshalb explizit als DB-Rolle beschriftet. */} {n.role === 'primary' ? t('dashboard.clusterCard.roleDbPrimary') : t('dashboard.clusterCard.rolePeer')}
))}
) } // ── HAProxy combined card ───────────────────────────────────── interface HAProxyFullCardProps { stats: { backends: HAProxyBackend[]; frontends: HAProxyFrontend[]; error?: string } resolveHAName: (n: string) => string } function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) { const { t } = useTranslation() const totalSessions = (stats.frontends ?? []).reduce((acc, f) => acc + f.sessions, 0) const totalReqRate = (stats.frontends ?? []).reduce((acc, f) => acc + f.req_rate, 0) const totalBytesIn = (stats.frontends ?? []).reduce((acc, f) => acc + f.bytes_in, 0) const totalBytesOut = (stats.frontends ?? []).reduce((acc, f) => acc + f.bytes_out, 0) return ( {t('dashboard.haproxyCard.title')}} extra={ stats.frontends.length > 0 && ( {totalSessions} sess {totalReqRate > 0 && {totalReqRate}/s} ↓{formatBytes(totalBytesIn)} ↑{formatBytes(totalBytesOut)} ) } > {stats.error && ( ⚠ HAProxy socket: {stats.error} )} {/* Listeners */} {stats.frontends.length > 0 && ( <> {t('dashboard.haproxyCard.frontends')}
{stats.frontends.map(f => ( {HA_FRONTEND_LABELS[f.name] ?? f.name} {f.sessions} sess{f.req_rate > 0 ? ` · ${f.req_rate}/s` : ''} ))}
)} {/* Backends */} {stats.backends.length === 0 && !stats.error ? ( {t('dashboard.haproxyCard.empty')} ) : ( <> Backends {stats.backends.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 card ──────────────────────────────────────────── function WGCard({ ifaces, status }: { ifaces: WGIface[]; status: WGStatusRow[] }) { const { t } = useTranslation() return ( {t('dashboard.wgCard.title')}} > {ifaces.length === 0 ? ( {t('dashboard.wgCard.empty')} ) : ( {ifaces.map(ifc => { const peers = status.filter(s => s.interface === ifc.name) const nowSec = Date.now() / 1000 const online = peers.filter(s => s.last_handshake_unix > 0 && nowSec - s.last_handshake_unix < 180).length const totalRx = peers.reduce((acc, s) => acc + s.transfer_rx, 0) const totalTx = peers.reduce((acc, s) => acc + s.transfer_tx, 0) return (
{ifc.name} {ifc.mode} {ifc.mode === 'server' && peers.length > 0 && (
{t('dashboard.wgCard.peersOnline', { online, total: peers.length })} {' · ▼'}{formatBytes(totalRx)}{' ▲'}{formatBytes(totalTx)}
)} {ifc.mode === 'client' && peers.map(s => (
{s.endpoint && {s.endpoint} · } {relativeTime(s.last_handshake_unix, t)} {' · ▼'}{formatBytes(s.transfer_rx)}{' ▲'}{formatBytes(s.transfer_tx)}
))}
) })}
)}
) } // ── Network services card ───────────────────────────────────── const NET_SERVICES = [ { unit: 'unbound', label: 'DNS', sub: 'Unbound', to: '/dns' }, { unit: 'squid', label: 'Forward Proxy', sub: 'Squid', to: '/forward-proxy' }, { unit: 'chrony', label: 'NTP', sub: 'Chrony', to: '/ntp' }, { unit: 'postgresql', label: 'Database', sub: 'PostgreSQL', to: undefined }, { unit: 'keepalived', label: 'VRRP', sub: 'keepalived', to: '/settings' }, ] function NetworkServicesCard({ services }: { services: ServiceStatus[] }) { const { t } = useTranslation() const svcMap = Object.fromEntries(services.map(s => [s.unit, s])) return ( {t('dashboard.networkServicesCard.title')}} > {NET_SERVICES.map(item => { const svc = svcMap[item.unit] const active = svc?.active ?? false return (
{item.label}
{item.sub}
{svc?.state ?? '—'} {item.to && ( {t('dashboard.networkServicesCard.configure')} → )}
) })}
) } // ── Firewall summary card ───────────────────────────────────── function FirewallSummaryCard({ zones, rules, nat }: { zones: FwZone[]; rules: number; nat: number }) { const { t } = useTranslation() return ( {t('dashboard.firewallCard.title')}} extra={{t('dashboard.networkServicesCard.configure')} →} > {zones.map(z => ( {z.name.toUpperCase()} ))} ) } // ── SSL summary card ────────────────────────────────────────── function SSLSummaryCard({ certs, now }: { certs: TLSCert[]; now: number }) { const { t } = useTranslation() const expired = certs.filter(c => c.not_after && new Date(c.not_after).getTime() < now) const soon = certs.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 ( {t('dashboard.sslCard.title')}} extra={{t('dashboard.networkServicesCard.configure')} →} > {expired.length > 0 && ( )} {soon.length > 0 && ( )} {expired.length === 0 && soon.length === 0 && (
✓ {t('dashboard.sslCard.allFresh')}
)}
) } // ── Routing summary card ────────────────────────────────────── function RoutingSummaryCard({ domains, backends }: { domains: Domain[]; backends: Backend[] }) { const { t } = useTranslation() const attached = domains.filter(d => d.primary_backend_id).length return ( {t('dashboard.routingCard.title')}} extra={{t('dashboard.networkServicesCard.configure')} →} >
{t('dashboard.routingCard.attached', { count: attached, total: domains.length })}
) } // ── Activity card ───────────────────────────────────────────── function ActivityCard({ entries }: { entries: AuditEntry[] }) { const { t } = useTranslation() return ( {t('dashboard.activityCard.title')}} extra={View all →} > {entries.length === 0 ? ( {t('dashboard.activityCard.empty')} ) : ( {entries.map(e => (
{e.action} {e.actor} {e.subject && {e.subject}} {relativeFromIso(e.created_at, t)}
))}
)}
) } // ── License chip ────────────────────────────────────────────── 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 label = days != null ? t('dashboard.licenseTrialDays', { days }) : t('dashboard.licenseTrial') if (days != null && days <= 7) return {label} if (days != null && days <= 14) return {label} return {label} } if (data.status === 'active') return {t('dashboard.licenseOk')} return null }