Auf utm-2 trug die Cluster-Karte an BEIDEN Knoten "DB-Primary" — auf utm-1 dagegen korrekt "primary" und "peer". Es kann aber nur einen DB-Primary geben, also war mindestens eine der beiden Ansichten falsch. Ursache: ha_nodes ist NICHT repliziert (node-lokale Tabelle, jeder Knoten fuehrt seine eigene Sicht), und in `role` traegt sich jeder Knoten selbst ein. Ein per Join dazugekommener Knoten behaelt dort den Default "primary" — utm-2 behauptete in seiner eigenen Ansicht also, es sei Primary. Der Code weiss das an anderer Stelle bereits: cluster_repair.go dokumentiert "role/pg_role sind je Node lokal und unzuverlaessig", und keepalived.go gibt pg_role deshalb seit v1.2.46 absoluten Vorrang vor role. Die Karte zeigt jetzt pg_role statt role. Das folgt der tatsaechlichen Replikationsrolle und stimmt auf beiden Seiten ueberein (utm-1=primary, utm-2=standby). 'standalone'/leer heisst "keine Replikation eingerichtet" und zeigt bewusst gar keine Rolle, statt eine zu erfinden. Die stale role='primary'-Zeile auf utm-2 wurde separat direkt in der node-lokalen DB korrigiert. Sie war nicht nur kosmetisch: faellt pg_role irgendwann aus (leer/'standalone'), greift in keepalived.go der role-Fallback — und genau diese Konstellation hat 2026-05 schon einmal einen Split-Brain ausgeloest (beide Knoten Prio 200, hoehere IP gewinnt). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1057 lines
46 KiB
TypeScript
1057 lines
46 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,
|
|
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<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) => {
|
|
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; pg_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<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, t: (k: string, v?: Record<string, unknown>) => 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<string, string> = {
|
|
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, unknown>) => string): string {
|
|
const ts = new Date(iso).getTime()
|
|
if (!ts) return ''
|
|
return relativeTime(Math.floor(ts / 1000), t)
|
|
}
|
|
|
|
const HA_FRONTEND_LABELS: Record<string, string> = {
|
|
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<AlertEvent>('/alerts/events?limit=10&open=true', '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 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<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') })
|
|
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<WGIface>('/wireguard/interfaces', 'interfaces') })
|
|
const wgStatus = useQuery({
|
|
queryKey: ['wg', 'status'],
|
|
queryFn: () => fetchList<WGStatusRow>('/wireguard/status', 'status'),
|
|
refetchInterval: 10_000,
|
|
})
|
|
const vipStatus = useQuery({
|
|
queryKey: ['system', 'vip-status'],
|
|
queryFn: () => fetchOne<VIPStatus>('/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<number, string>()
|
|
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<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) {
|
|
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 (
|
|
<div>
|
|
<PageHeader
|
|
icon={<DashboardOutlined />}
|
|
title={t('dashboard.title')}
|
|
subtitle={t('dashboard.welcomeHint')}
|
|
extra={
|
|
<Space>
|
|
<UpdateBanner compact />
|
|
{license.data && <LicenseChip data={license.data} />}
|
|
<Tag color="blue">v{health.data?.version ?? '—'}</Tag>
|
|
<StatusDot active={health.data?.status === 'ok'} />
|
|
</Space>
|
|
}
|
|
/>
|
|
|
|
{/* ─ Onboarding ─────────────────────────────────────── */}
|
|
{(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>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* ─ Alert bar (compact) ────────────────────────────── */}
|
|
{alerts.length > 0 && (
|
|
<Alert
|
|
type={nCritical > 0 ? 'error' : 'warning'}
|
|
showIcon
|
|
icon={<BellOutlined />}
|
|
className="mb-12"
|
|
message={
|
|
<Space size={12}>
|
|
<span style={{ fontWeight: 600 }}>{t('dashboard.alertsCard.title')}</span>
|
|
<span style={{ fontSize: 12, opacity: 0.85 }}>
|
|
{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 })
|
|
}
|
|
</span>
|
|
</Space>
|
|
}
|
|
action={<Link to="/alerts?tab=events" style={{ fontSize: 12 }}>{t('dashboard.alertsCard.viewAll')} →</Link>}
|
|
/>
|
|
)}
|
|
|
|
{/* ─ Operational alerts ─────────────────────────────── */}
|
|
{downBackends.length > 0 && !isBackup && (
|
|
<Alert
|
|
type="error" showIcon className="mb-12"
|
|
message={t('dashboard.downBackendsAlert', { count: downBackends.length })}
|
|
description={<Space wrap size={4}>{downBackends.map(n => <Link key={n} to="/backends">{n}</Link>)}</Space>}
|
|
/>
|
|
)}
|
|
{downBackends.length > 0 && isBackup && (
|
|
<Alert
|
|
type="info" showIcon className="mb-12"
|
|
message={t('dashboard.backupNodeBackends')}
|
|
description={t('dashboard.backupNodeBackendsDesc')}
|
|
/>
|
|
)}
|
|
{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 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 ────────────────────────────────── */}
|
|
<Row gutter={[12, 12]} className="mb-12">
|
|
<ResourcesCard r={resources.data} />
|
|
</Row>
|
|
|
|
{/* ─ Services health bar ────────────────────────────── */}
|
|
{(services.data?.length ?? 0) > 0 && (
|
|
<ServicesBar services={services.data ?? []} />
|
|
)}
|
|
|
|
{/* ─ HA/Cluster row ─────────────────────────────────── */}
|
|
<Row gutter={[12, 12]} className="mb-12">
|
|
<Col xs={24} lg={12}>
|
|
<VIPCard data={vipStatus.data} />
|
|
</Col>
|
|
<Col xs={24} lg={12}>
|
|
<ClusterStatusCard nodes={cluster.data ?? []} status={clusterStatus.data ?? null} />
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* ─ Traffic row ────────────────────────────────────── */}
|
|
<Row gutter={[12, 12]} className="mb-12">
|
|
<Col xs={24} lg={15}>
|
|
<HAProxyFullCard
|
|
stats={haproxyStats.data ?? { backends: [], frontends: [] }}
|
|
resolveHAName={resolveHAName}
|
|
/>
|
|
</Col>
|
|
<Col xs={24} lg={9}>
|
|
<WGCard ifaces={wgIfaces.data ?? []} status={wgStatus.data ?? []} />
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* ─ Infrastructure row ─────────────────────────────── */}
|
|
<Row gutter={[12, 12]} className="mb-12">
|
|
<Col xs={24} md={8}>
|
|
<NetworkServicesCard services={services.data ?? []} />
|
|
</Col>
|
|
<Col xs={24} md={8}>
|
|
<FirewallSummaryCard zones={fwZones.data ?? []} rules={activeFwRules} nat={activeNAT} />
|
|
</Col>
|
|
<Col xs={24} md={8}>
|
|
<SSLSummaryCard certs={tlsCerts.data ?? []} now={now} />
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* ─ Routing + Activity row ─────────────────────────── */}
|
|
<Row gutter={[12, 12]}>
|
|
<Col xs={24} md={12}>
|
|
<RoutingSummaryCard domains={domains.data ?? []} backends={backends.data ?? []} />
|
|
</Col>
|
|
<Col xs={24} md={12}>
|
|
<ActivityCard entries={auditEntries} />
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Sub-components ────────────────────────────────────────────
|
|
|
|
// ── KPI tile ─────────────────────────────────────────────────
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
// ── 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 (
|
|
<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')}
|
|
{r.num_cpus != null && <span style={{ fontWeight: 400, opacity: 0.7 }}> ({r.num_cpus} CPU)</span>}
|
|
</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>
|
|
)
|
|
}
|
|
|
|
// ── Services health bar ───────────────────────────────────────
|
|
|
|
const SVC_STATE_COLOR: Record<string, { bg: string; border: string; text: string }> = {
|
|
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 (
|
|
<Card size="small" className="mb-12">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
|
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5, marginRight: 4, whiteSpace: 'nowrap' }}>
|
|
{t('dashboard.servicesCard.title')}
|
|
</Text>
|
|
{services.map(s => {
|
|
const c = SVC_STATE_COLOR[s.state] ?? SVC_DEFAULT_COLORS
|
|
return (
|
|
<Tooltip key={s.unit} title={s.since ? `${s.state} — ${s.since}` : s.state}>
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
|
padding: '2px 8px', borderRadius: 4,
|
|
fontSize: 11, fontWeight: 500, cursor: 'default',
|
|
background: c.bg, border: `1px solid ${c.border}`, color: c.text,
|
|
}}>
|
|
<StatusDot active={s.active} />
|
|
{s.label}
|
|
</span>
|
|
</Tooltip>
|
|
)
|
|
})}
|
|
</div>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// ── VIP / VRRP card ───────────────────────────────────────────
|
|
|
|
const VRRP_STATE_COLOR: Record<string, string> = {
|
|
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 (
|
|
<Card
|
|
size="small"
|
|
className="h-100"
|
|
title={
|
|
<Space size={6}>
|
|
<ThunderboltOutlined style={{ color: '#0EA5E9' }} />
|
|
<span>{t('dashboard.vipCard.title')}</span>
|
|
</Space>
|
|
}
|
|
extra={
|
|
<Space size={4}>
|
|
{data && (
|
|
<Tag
|
|
color={stateColor}
|
|
style={{ fontWeight: 700, fontSize: 12, letterSpacing: 0.5 }}
|
|
>
|
|
{stateLabel}
|
|
</Tag>
|
|
)}
|
|
{data && !data.keepalived_active && (
|
|
<Tag color="red" style={{ fontSize: 10 }}>{t('dashboard.vipCard.keepalivedInactive')}</Tag>
|
|
)}
|
|
</Space>
|
|
}
|
|
>
|
|
{!data ? (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>—</Text>
|
|
) : (data.vips ?? []).length === 0 ? (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.vipCard.noVips')}</Text>
|
|
) : (
|
|
<Space direction="vertical" style={{ width: '100%' }} size={0}>
|
|
{(data.vips ?? []).map((v) => (
|
|
<div key={v.address} style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '5px 0', borderBottom: '1px solid #F1F5F9',
|
|
}}>
|
|
<Space size={8}>
|
|
<StatusDot active={v.active} />
|
|
<code style={{ fontSize: 13, fontWeight: 600, color: '#0F172A' }}>{v.address}/{v.prefix}</code>
|
|
</Space>
|
|
<Space size={4}>
|
|
<Tag color={v.active ? 'green' : 'default'} style={{ margin: 0, fontSize: 10 }}>
|
|
{v.active ? 'active' : 'standby'}
|
|
</Tag>
|
|
{v.device && (
|
|
<Text type="secondary" style={{ fontSize: 11 }}>dev {v.device}</Text>
|
|
)}
|
|
</Space>
|
|
</div>
|
|
))}
|
|
</Space>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// ── Cluster card ──────────────────────────────────────────────
|
|
|
|
// dbRoleLabel/-Color bilden pg_role ab. 'standalone' bzw. leer heisst:
|
|
// keine Replikation eingerichtet — dann gibt es schlicht keine DB-Rolle
|
|
// zu zeigen, statt eine zu erfinden.
|
|
function dbRoleLabel(pgRole: string | undefined, t: (k: string) => string): string {
|
|
switch (pgRole) {
|
|
case 'primary': return t('dashboard.clusterCard.roleDbPrimary')
|
|
case 'standby': return t('dashboard.clusterCard.roleDbStandby')
|
|
default: return t('dashboard.clusterCard.roleDbUnknown')
|
|
}
|
|
}
|
|
function dbRoleColor(pgRole: string | undefined): string {
|
|
switch (pgRole) {
|
|
case 'primary': return 'green'
|
|
case 'standby': return 'blue'
|
|
default: return 'default'
|
|
}
|
|
}
|
|
|
|
interface ClusterStatusCardProps {
|
|
nodes: ClusterNode[]
|
|
status: { mode: string; health: string; drift_found: boolean } | null
|
|
}
|
|
function ClusterStatusCard({ nodes, status }: ClusterStatusCardProps) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<Card
|
|
size="small"
|
|
className="h-100"
|
|
title={<><ApartmentOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.clusterCard.title')}</>}
|
|
extra={status && (
|
|
<Space size={4}>
|
|
<Tag color={status.mode === 'cluster' ? 'blue' : 'default'}>
|
|
{status.mode === 'cluster' ? t('dashboard.clusterCard.modeCluster') : t('dashboard.clusterCard.modeSingle')}
|
|
</Tag>
|
|
<Tag color={status.health === 'ok' ? 'green' : status.health === 'degraded' ? 'orange' : 'red'}>
|
|
{t(`dashboard.clusterCard.health.${status.health}`)}
|
|
</Tag>
|
|
</Space>
|
|
)}
|
|
>
|
|
<Statistic title={t('dashboard.clusterCard.nodes')} value={nodes.length} />
|
|
{status?.drift_found && (
|
|
<Tag color="red" style={{ marginTop: 8 }}>{t('dashboard.clusterCard.drift')}</Tag>
|
|
)}
|
|
<Space direction="vertical" style={{ marginTop: 8, width: '100%' }} size={3}>
|
|
{nodes.map(n => (
|
|
<div key={n.id} style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12,
|
|
}}>
|
|
<code style={{ color: '#334155' }}>{n.fqdn}</code>
|
|
{/* Bewusst pg_role, NICHT role: ha_nodes ist node-lokal (nicht
|
|
repliziert), und jeder Node traegt sich in `role` selbst ein —
|
|
ein per Join dazugekommener Node behaelt dort den Default
|
|
"primary" und behauptete deshalb in seiner eigenen Ansicht,
|
|
beide Knoten seien DB-Primary. `pg_role` folgt der
|
|
tatsaechlichen Replikationsrolle und stimmt auf beiden Seiten
|
|
ueberein. Siehe auch cluster_repair.go: role/pg_role sind
|
|
node-lokal, verlaesslich ist letztlich die PUBLICATION.
|
|
Die DB-Rolle wandert NICHT mit der VIP — sie aendert sich nur
|
|
durch `edgeguard-ctl promote`. */}
|
|
<Tooltip title={t('dashboard.clusterCard.roleHint')}>
|
|
<Tag color={dbRoleColor(n.pg_role)} style={{ margin: 0 }}>
|
|
{dbRoleLabel(n.pg_role, t)}
|
|
</Tag>
|
|
</Tooltip>
|
|
</div>
|
|
))}
|
|
</Space>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// ── 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 (
|
|
<Card
|
|
size="small"
|
|
className="h-100"
|
|
title={<><DatabaseOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.haproxyCard.title')}</>}
|
|
extra={
|
|
(stats.frontends ?? []).length > 0 && (
|
|
<Space size={8}>
|
|
<Text type="secondary" style={{ fontSize: 11 }}>{totalSessions} sess</Text>
|
|
{totalReqRate > 0 && <Text type="secondary" style={{ fontSize: 11 }}>{totalReqRate}/s</Text>}
|
|
<Text type="secondary" style={{ fontSize: 11 }}>↓{formatBytes(totalBytesIn)} ↑{formatBytes(totalBytesOut)}</Text>
|
|
</Space>
|
|
)
|
|
}
|
|
>
|
|
{stats.error && (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
⚠ HAProxy socket: <code style={{ fontSize: 11 }}>{stats.error}</code>
|
|
</Text>
|
|
)}
|
|
|
|
{/* Listeners */}
|
|
{(stats.frontends ?? []).length > 0 && (
|
|
<>
|
|
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
{t('dashboard.haproxyCard.frontends')}
|
|
</Text>
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '4px 0 10px' }}>
|
|
{stats.frontends.map(f => (
|
|
<span key={f.name} style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
|
padding: '2px 8px', borderRadius: 4, fontSize: 11,
|
|
background: '#F0F9FF', border: '1px solid #BAE6FD', color: '#0369A1',
|
|
}}>
|
|
<span style={{ fontWeight: 600 }}>{HA_FRONTEND_LABELS[f.name] ?? f.name}</span>
|
|
<span style={{ opacity: 0.8 }}>{f.sessions} sess{f.req_rate > 0 ? ` · ${f.req_rate}/s` : ''}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Backends */}
|
|
{(stats.backends ?? []).length === 0 && !stats.error ? (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.haproxyCard.empty')}</Text>
|
|
) : (
|
|
<>
|
|
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
Backends
|
|
</Text>
|
|
<Space direction="vertical" style={{ width: '100%', marginTop: 4 }} size={0}>
|
|
{stats.backends.map((b, i) => (
|
|
<div key={i} style={{
|
|
display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap',
|
|
padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12,
|
|
}}>
|
|
<code style={{ fontSize: 11, color: '#334155' }}>{resolveHAName(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>
|
|
{b.health && b.health !== 'L7OK' && b.health !== 'L4OK' && (
|
|
<Tooltip title={haCheckHint(b.health)}>
|
|
<Tag color="volcano" style={{ margin: 0, fontSize: 10, cursor: 'help' }}>{b.health}</Tag>
|
|
</Tooltip>
|
|
)}
|
|
<Text type="secondary" style={{ fontSize: 11, marginLeft: 'auto' }}>
|
|
{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 }}>{formatUptime(b.last_change_sec)}</Text>
|
|
</div>
|
|
))}
|
|
</Space>
|
|
</>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// ── WireGuard card ────────────────────────────────────────────
|
|
|
|
function WGCard({ ifaces, status }: { ifaces: WGIface[]; status: WGStatusRow[] }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<Card
|
|
size="small"
|
|
className="h-100"
|
|
title={<><ThunderboltOutlined style={{ color: '#8B5CF6' }} /> {t('dashboard.wgCard.title')}</>}
|
|
>
|
|
{ifaces.length === 0 ? (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.wgCard.empty')}</Text>
|
|
) : (
|
|
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
|
{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 (
|
|
<div key={ifc.id} style={{ borderBottom: '1px solid #F1F5F9', paddingBottom: 6 }}>
|
|
<Space size={6}>
|
|
<code style={{ fontWeight: 600, fontSize: 12 }}>{ifc.name}</code>
|
|
<Tag color={ifc.mode === 'server' ? 'blue' : 'purple'} style={{ fontSize: 10 }}>{ifc.mode}</Tag>
|
|
<StatusDot active={ifc.active} />
|
|
</Space>
|
|
{ifc.mode === 'server' && peers.length > 0 && (
|
|
<div style={{ fontSize: 11, color: '#64748B', marginTop: 2 }}>
|
|
{t('dashboard.wgCard.peersOnline', { online, total: peers.length })}
|
|
{' · ▼'}{formatBytes(totalRx)}{' ▲'}{formatBytes(totalTx)}
|
|
</div>
|
|
)}
|
|
{ifc.mode === 'client' && peers.map(s => (
|
|
<div key={s.peer_public_key} style={{ fontSize: 11, color: '#64748B', marginTop: 2 }}>
|
|
{s.endpoint && <span>{s.endpoint} · </span>}
|
|
<span>{relativeTime(s.last_handshake_unix, t)}</span>
|
|
{' · ▼'}{formatBytes(s.transfer_rx)}{' ▲'}{formatBytes(s.transfer_tx)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
})}
|
|
</Space>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// ── 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 (
|
|
<Card
|
|
size="small"
|
|
className="h-100"
|
|
title={<><NodeIndexOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.networkServicesCard.title')}</>}
|
|
>
|
|
<Space direction="vertical" style={{ width: '100%' }} size={0}>
|
|
{NET_SERVICES.map(item => {
|
|
const svc = svcMap[item.unit]
|
|
const active = svc?.active ?? false
|
|
return (
|
|
<div key={item.unit} style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '6px 0', borderBottom: '1px solid #F1F5F9',
|
|
}}>
|
|
<Space size={8}>
|
|
<StatusDot active={active} />
|
|
<div>
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: '#0F172A', lineHeight: 1.3 }}>{item.label}</div>
|
|
<div style={{ fontSize: 10, color: '#94A3B8', lineHeight: 1.2 }}>{item.sub}</div>
|
|
</div>
|
|
</Space>
|
|
<Space size={6}>
|
|
<Tag
|
|
color={active ? 'green' : svc?.state === 'failed' ? 'red' : 'default'}
|
|
style={{ margin: 0, fontSize: 10 }}
|
|
>
|
|
{svc?.state ?? '—'}
|
|
</Tag>
|
|
{item.to && (
|
|
<Link to={item.to} style={{ fontSize: 11, color: '#64748B' }}>
|
|
{t('dashboard.networkServicesCard.configure')} →
|
|
</Link>
|
|
)}
|
|
</Space>
|
|
</div>
|
|
)
|
|
})}
|
|
</Space>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// ── Firewall summary card ─────────────────────────────────────
|
|
|
|
function FirewallSummaryCard({ zones, rules, nat }: { zones: FwZone[]; rules: number; nat: number }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<Card
|
|
size="small"
|
|
className="h-100"
|
|
title={<><FireOutlined style={{ color: '#EF4444' }} /> {t('dashboard.firewallCard.title')}</>}
|
|
extra={<Link to="/firewall" style={{ fontSize: 11 }}>{t('dashboard.networkServicesCard.configure')} →</Link>}
|
|
>
|
|
<Row gutter={[8, 8]}>
|
|
<Col span={12}>
|
|
<Statistic title={t('dashboard.firewallCard.zones')} value={zones.length} />
|
|
</Col>
|
|
<Col span={12}>
|
|
<Statistic title="Rules / NAT" value={`${rules} / ${nat}`} />
|
|
</Col>
|
|
</Row>
|
|
<Space wrap style={{ marginTop: 8 }} size={4}>
|
|
{zones.map(z => (
|
|
<Tag key={z.id} color={z.builtin ? 'blue' : 'gold'} style={{ fontSize: 10 }}>{z.name.toUpperCase()}</Tag>
|
|
))}
|
|
</Space>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// ── 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 (
|
|
<Card
|
|
size="small"
|
|
className="h-100"
|
|
title={<><SafetyCertificateOutlined style={{ color: '#10B981' }} /> {t('dashboard.sslCard.title')}</>}
|
|
extra={<Link to="/ssl" style={{ fontSize: 11 }}>{t('dashboard.networkServicesCard.configure')} →</Link>}
|
|
>
|
|
<Statistic title={t('dashboard.sslCard.total')} value={certs.length} />
|
|
{expired.length > 0 && (
|
|
<Alert style={{ marginTop: 8 }} type="error" showIcon message={t('dashboard.sslCard.certExpired', { count: expired.length })} />
|
|
)}
|
|
{soon.length > 0 && (
|
|
<Alert style={{ marginTop: 8 }} type="warning" showIcon message={t('dashboard.sslCard.expiringSoon', { count: soon.length })} />
|
|
)}
|
|
{expired.length === 0 && soon.length === 0 && (
|
|
<div style={{ marginTop: 8, fontSize: 12, color: '#10B981' }}>✓ {t('dashboard.sslCard.allFresh')}</div>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// ── 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 (
|
|
<Card
|
|
size="small"
|
|
className="h-100"
|
|
title={<><GlobalOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.routingCard.title')}</>}
|
|
extra={<Link to="/domains" style={{ fontSize: 11 }}>{t('dashboard.networkServicesCard.configure')} →</Link>}
|
|
>
|
|
<Row gutter={[8, 0]}>
|
|
<Col span={12}><Statistic title={t('dashboard.routingCard.domains')} value={domains.length} /></Col>
|
|
<Col span={12}><Statistic title={t('dashboard.routingCard.backends')} value={backends.length} /></Col>
|
|
</Row>
|
|
<div style={{ marginTop: 8, fontSize: 12, color: '#64748B' }}>
|
|
{t('dashboard.routingCard.attached', { count: attached, total: domains.length })}
|
|
</div>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// ── Activity card ─────────────────────────────────────────────
|
|
|
|
function ActivityCard({ entries }: { entries: AuditEntry[] }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<Card
|
|
size="small"
|
|
className="h-100"
|
|
title={<><ApiOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.activityCard.title')}</>}
|
|
extra={<Link to="/audit" style={{ fontSize: 11 }}>View all →</Link>}
|
|
>
|
|
{entries.length === 0 ? (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.activityCard.empty')}</Text>
|
|
) : (
|
|
<Space direction="vertical" style={{ width: '100%' }} size={0}>
|
|
{entries.map(e => (
|
|
<div key={e.id} style={{
|
|
fontSize: 12, color: '#334155',
|
|
borderBottom: '1px solid #F1F5F9', padding: '5px 0',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: '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, t)}
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</Space>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// ── 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 <Tag color="red">{data.status}</Tag>
|
|
}
|
|
if (isTrial) {
|
|
const label = days != null ? t('dashboard.licenseTrialDays', { days }) : t('dashboard.licenseTrial')
|
|
if (days != null && days <= 7) return <Tag color="red">{label}</Tag>
|
|
if (days != null && days <= 14) return <Tag color="orange">{label}</Tag>
|
|
return <Tag color="blue">{label}</Tag>
|
|
}
|
|
if (data.status === 'active') return <Tag color="green">{t('dashboard.licenseOk')}</Tag>
|
|
return null
|
|
}
|