import { Alert, Button, Card, Descriptions, Input, Modal, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd' import type { ColumnsType } from 'antd/es/table' import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import apiClient, { isEnvelope } from '../../api/client' import PageHeader from '../../components/PageHeader' const { Text } = Typography interface HANode { id: string name: string fqdn: string api_url: string public_ip?: string | null internal_ip?: string | null mgmt_ip?: string | null role: string version?: string | null config_hash?: string | null status: 'online' | 'offline' | 'joining' | 'leaving' | 'unknown' last_seen?: string | null joined_at: string } interface ClusterStatus { local_id: string local_node?: HANode | null peers: HANode[] mode: 'single-node' | 'cluster' health: 'ok' | 'degraded' | 'split-brain' drift_found: boolean updated_at: string } interface NodeResources { load_avg_1: number load_avg_5: number load_avg_15: number mem_used_pct: number mem_total_kb: number mem_avail_kb: number disk_used_pct: number disk_total_gb: number disk_free_gb: number conntrack_count: number conntrack_max: number uptime_sec: number } interface PeerLoadResult { node_id: string fqdn: string ok: boolean data?: NodeResources error?: string duration_ms: number } interface JoinTokenResponse { token: string expires_at: string ca_fingerprint: string } interface CertInfo { common_name: string not_before: string not_after: string days_remaining: number is_ca: boolean serial_hex: string } interface CertStatus { has_ca: boolean has_peer: boolean ca?: CertInfo peer?: CertInfo } function statusTag(s: HANode['status']) { switch (s) { case 'online': return online case 'offline': return offline case 'joining': return joining case 'leaving': return leaving default: return unknown } } function lastSeenRelative(iso: string | null | undefined, now: number): string { if (!iso) return '—' const ms = now - new Date(iso).getTime() if (ms < 0) return '—' if (ms < 60_000) return `${Math.round(ms / 1000)}s` if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m` return `${Math.round(ms / 3_600_000)}h` } // useTickingNow gibt einen `now`-Wert zurück der jede Sekunde // re-rendert. Damit tickt das Cluster-Last-Seen-Label visuell jede // Sekunde, statt nur alle 30s beim useQuery-Refetch. function useTickingNow(intervalMs = 1000): number { const [now, setNow] = useState(() => Date.now()) useEffect(() => { const t = setInterval(() => setNow(Date.now()), intervalMs) return () => clearInterval(t) }, [intervalMs]) return now } export default function ClusterPage() { const { t } = useTranslation() const now = useTickingNow() const { data, isLoading } = useQuery({ queryKey: ['cluster', 'status'], queryFn: async () => { const r = await apiClient.get('/cluster/status') return isEnvelope(r.data) ? (r.data.data as ClusterStatus) : null }, refetchInterval: 30_000, }) // Phase 3.3: per-Node Load via mTLS-Aggregator. Single-node = nur die // eigene Zeile. Multi-Node = pro Peer eine. refetchInterval bewusst // langsamer als /cluster/status weil fan-out N×3s Netzwerk-Roundtrips // bedeuten kann. const loadQuery = useQuery({ queryKey: ['cluster', 'system-load'], queryFn: async () => { const r = await apiClient.get('/cluster/system/load') const payload = isEnvelope(r.data) ? (r.data.data as { nodes?: PeerLoadResult[] }) : null return payload?.nodes ?? [] }, refetchInterval: 60_000, }) const certStatus = useQuery({ queryKey: ['cluster', 'cert-status'], queryFn: async () => { const r = await apiClient.get('/cluster/cert-status') return isEnvelope(r.data) ? (r.data.data as CertStatus) : null }, refetchInterval: 5 * 60_000, }) const qc = useQueryClient() const removePeer = useMutation({ mutationFn: async (id: string) => apiClient.delete(`/cluster/nodes/${id}`), onSuccess: () => { message.success(t('cluster.removePeerOk')) void qc.invalidateQueries({ queryKey: ['cluster'] }) }, onError: (e: Error) => message.error(t('cluster.removePeerFailed') + ': ' + e.message), }) const renewSelf = useMutation({ mutationFn: async () => { const r = await apiClient.post('/cluster/renew-self') return r.data }, onSuccess: () => { message.success(t('cluster.certRenewedRestartHint')) void certStatus.refetch() }, onError: (e: Error) => { message.error(t('cluster.certRenewFailed') + ': ' + e.message) }, }) const [joinTokenOpen, setJoinTokenOpen] = useState(false) const [joinToken, setJoinToken] = useState(null) const generateToken = useMutation({ mutationFn: async () => { const r = await apiClient.post('/cluster/join-tokens') return isEnvelope(r.data) ? (r.data.data as JoinTokenResponse) : null }, onSuccess: (t) => { setJoinToken(t) setJoinTokenOpen(true) }, onError: () => { message.error(t('cluster.joinTokenFailed')) }, }) if (isLoading) return if (!data) return null const primaryFqdn = data.local_node?.fqdn ?? '' const joinCmd = joinToken ? `sudo edgeguard-ctl cluster-join ${primaryFqdn} \\\n --token ${joinToken.token}` : '' const peerColumns: ColumnsType = [ { title: t('cluster.col.node'), key: 'node', render: (_, r) => (
{r.fqdn}
{r.id}
), }, { title: t('cluster.col.status'), dataIndex: 'status', width: 110, render: (s: HANode['status']) => statusTag(s), }, { title: t('cluster.col.role'), dataIndex: 'role', width: 110, render: (v: string) => {v} }, { title: t('cluster.col.apiUrl'), dataIndex: 'api_url', width: 240, render: (v: string) => {v} }, { title: t('cluster.col.configHash'), dataIndex: 'config_hash', width: 160, render: (v: string | null | undefined) => { if (!v) return const localHash = data.local_node?.config_hash const drifts = localHash && v !== localHash return ( {v.slice(0, 12)}… {drifts && {t('cluster.drift')}} ) }, }, { title: t('cluster.col.version'), dataIndex: 'version', width: 100, render: (v?: string | null) => v ? {v} : }, { title: t('cluster.col.lastSeen'), dataIndex: 'last_seen', width: 110, render: (v: string | null | undefined, r: HANode) => { const rel = lastSeenRelative(v, now) const tipText = v ? new Date(v).toLocaleString() : t('cluster.col.lastSeen') const stale = r.status !== 'online' return ( {rel} ) }, }, { title: t('common.actions'), key: 'actions', width: 110, render: (_, r) => ( removePeer.mutate(r.id)} > ), }, ] return (
} title={t('cluster.title')} subtitle={t('cluster.intro', { count: 1 + data.peers.length })} extra={ {data.mode === 'cluster' ? t('cluster.modeCluster') : t('cluster.modeSingle')} {t(`cluster.health.${data.health}`)} } /> {data.drift_found && ( )} {data.mode === 'single-node' && ( )} {data.local_node ? ( {data.local_node.fqdn} {data.local_node.id} {statusTag(data.local_node.status)} {data.local_node.role} {data.local_node.version ? {data.local_node.version} : '—'} {lastSeenRelative(data.local_node.last_seen, now)} {data.local_node.mgmt_ip || '—'} {data.local_node.api_url} {data.local_node.config_hash || '—'} ) : ( {t('cluster.noSelf')} )} {(certStatus.data?.has_ca || certStatus.data?.has_peer) && ( renewSelf.mutate()} > )} > {certStatus.data.ca && ( <> {certStatus.data.ca.common_name} )} {certStatus.data.peer && ( <> {certStatus.data.peer.common_name} )} )} {data.peers.length > 0 && ( )} {/* Per-Node Resources via mTLS-Aggregator (Phase 3.3). Bei Single-Node 1 Zeile; bei Cluster N. duration_ms zeigt welcher Peer langsam ist (Netzwerk-Latenz oder Last). */} size="small" rowKey="node_id" dataSource={loadQuery.data ?? []} pagination={false} locale={{ emptyText: t('cluster.loadEmpty') }} columns={[ { title: t('cluster.col.node'), key: 'node', render: (_, r) => ( {r.fqdn || r.node_id} {!r.ok && {r.error || 'error'}} ), }, { title: t('cluster.col.load'), key: 'load', width: 110, render: (_, r) => r.ok && r.data ? {r.data.load_avg_1.toFixed(2)} / {r.data.load_avg_5.toFixed(2)} / {r.data.load_avg_15.toFixed(2)} : , }, { title: t('cluster.col.mem'), key: 'mem', width: 110, render: (_, r) => r.ok && r.data ? {r.data.mem_used_pct.toFixed(0)}% : , }, { title: t('cluster.col.disk'), key: 'disk', width: 110, render: (_, r) => r.ok && r.data ? {r.data.disk_used_pct.toFixed(0)}% : , }, { title: t('cluster.col.conntrack'), key: 'ct', width: 130, render: (_, r) => r.ok && r.data ? {r.data.conntrack_count}/{r.data.conntrack_max} : , }, { title: t('cluster.col.uptime'), key: 'up', width: 100, render: (_, r) => r.ok && r.data ? {formatUptime(r.data.uptime_sec)} : , }, { title: t('cluster.col.fetchMs'), key: 'ms', width: 80, render: (_, r) => ( {r.duration_ms}ms ), }, ]} /> setJoinTokenOpen(false)} footer={} width={720} > {joinToken ? ( {joinToken.ca_fingerprint}
{t('cluster.joinCmdLabel')}
) : null}
) } // CertExpiry rendert " Tage" mit Farbcode: rot < 30, orange < 90, // grün sonst. Tooltip zeigt das absolute NotAfter-Datum. function CertExpiry({ days, until }: { days: number; until: string }) { const { t } = useTranslation() let color: string | undefined if (days < 0) color = '#cf1322' // already expired else if (days < 30) color = '#cf1322' // critical else if (days < 90) color = '#d4651a' // warning else color = '#52c41a' // healthy const label = days < 0 ? t('cluster.certExpiredDaysAgo', { n: -days }) : t('cluster.certDaysRemaining', { n: days }) return ( {label} ) } // formatUptime liefert "Xd Yh" oder "Xh Ym" oder "Xm" — kompakter als // die Sekunden-Zahl. function formatUptime(sec: number): string { if (!sec || sec < 0) return '—' const d = Math.floor(sec / 86400) const h = Math.floor((sec % 86400) / 3600) const m = Math.floor((sec % 3600) / 60) if (d > 0) return `${d}d ${h}h` if (h > 0) return `${h}h ${m}m` return `${m}m` }