From 33f6fef009f2440c56da11bde96d9121a738e445 Mon Sep 17 00:00:00 2001 From: Debian Date: Fri, 29 May 2026 15:17:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(cluster):=20Seite=20komplett=20neu=20?= =?UTF-8?q?=E2=80=94=20Blank-Page-Fix=20+=20Node-Join-Wizard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seite war komplett leer wenn die Cluster-API einen Fehler zurückgab (if(!data) return null). Jetzt: sichtbarer Error-Banner mit Retry, keine leere Seite mehr. Neuer 4-Schritt-Wizard "Zweiten Node hinzufügen" immer sichtbar: 1. Installer-Oneliner (copyable) 2. Join-Token generieren (Button → POST /cluster/join-tokens) 3. cluster-join-Befehl inkl. Token inline auf der Seite (kein Modal) 4. systemctl restart edgeguard-api Token + CA-Fingerprint + Befehl erscheinen direkt in Schritt 3 nach Token-Generierung — kein separates Modal mehr nötig. common.retry i18n-Key ergänzt. Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- management-ui/src/i18n/locales/de/common.json | 15 +- management-ui/src/i18n/locales/en/common.json | 15 +- management-ui/src/pages/Cluster/index.tsx | 498 ++++++++++-------- 7 files changed, 320 insertions(+), 216 deletions(-) diff --git a/VERSION b/VERSION index 032f5c7..b59e8c3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.145 +1.1.146 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 4c29bed..2955841 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -60,7 +60,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.1.145" +var version = "1.1.146" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 00f09e8..d81a8ec 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -11,7 +11,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) -var version = "1.1.145" +var version = "1.1.146" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index f737b1f..a32a22e 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -41,7 +41,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.1.145" +var version = "1.1.146" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index b4f45df..b97fefd 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -448,7 +448,19 @@ "noSelf": "Selbst-Registrierung in ha_nodes fehlgeschlagen — Setup-Wizard durchlaufen.", "peersTitle": "Peers ({{count}})", "singleNodeTitle": "Single-Node-Modus", - "singleNodeDesc": "Nur diese Box bekannt. Zusätzlicher Knoten? `edgeguard-ctl cluster-join` (kommt in Phase 3.2).", + "singleNodeDesc": "Nur diese Box bekannt. Folge den Schritten unten um einen zweiten Node beizutreten.", + "addNodeTitle": "Zweiten Node dem Cluster hinzufügen", + "step1Title": "1 — EdgeGuard auf dem neuen Host installieren", + "step1Desc": "Als root auf dem neuen Host ausführen (Debian 13, amd64):", + "step2Title": "2 — Join-Token hier generieren (auf diesem Primary-Node)", + "step2Desc": "Erzeugt einen einmaligen HMAC-Token, der 30 Minuten gültig ist. Der Token autorisiert den neuen Node ein signiertes Cluster-TLS-Zertifikat zu erhalten.", + "step3Title": "3 — Auf dem neuen Node ausführen", + "step3NoToken": "Zuerst oben auf \"Join-Token erzeugen\" klicken — der vollständige Befehl erscheint dann hier.", + "step4Title": "4 — edgeguard-api auf dem neuen Node neu starten", + "step4Desc": "Der Dienst lädt das neue Cluster-TLS-Zertifikat und beginnt Heartbeats zu senden. Der Node erscheint innerhalb von 30 Sekunden in der Peers-Tabelle.", + "apiUnreachable": "Cluster-API nicht erreichbar", + "tokenLabel": "Token", + "caFingerprintLabel": "CA-Fingerabdruck", "driftBanner": "Config-Drift erkannt", "driftBannerDesc": "Ein oder mehrere Peers haben einen anderen Config-Hash als dieser Node. Entweder stehen noch Änderungen in der Outbox, oder auf einem Peer wurde direkt in der DB editiert. Warte bis die Outbox leer ist oder starte Diagnostics.", "col": { @@ -1069,6 +1081,7 @@ "download": "Download", "copy": "Kopieren", "copied": "Kopiert", + "retry": "Erneut versuchen", "close": "Schließen", "refresh": "Aktualisieren", "up": "UP", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 29efae7..7ef7944 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -448,7 +448,19 @@ "noSelf": "Self-registration in ha_nodes failed — run the setup wizard.", "peersTitle": "Peers ({{count}})", "singleNodeTitle": "Single-node mode", - "singleNodeDesc": "Only this box is known. To add another node: `edgeguard-ctl cluster-join` (coming in phase 3.2).", + "singleNodeDesc": "Only this box is known. Follow the steps below to join a second node.", + "addNodeTitle": "Add second node to cluster", + "step1Title": "1 — Install EdgeGuard on the new host", + "step1Desc": "Run as root on the new host (Debian 13, amd64):", + "step2Title": "2 — Generate a join token here (on this primary node)", + "step2Desc": "Creates a one-time HMAC token valid for 30 minutes. The token authorises the new node to receive a signed cluster TLS certificate.", + "step3Title": "3 — Run on the new node", + "step3NoToken": "Click \"Generate join token\" above — the full command will appear here.", + "step4Title": "4 — Restart edgeguard-api on the new node", + "step4Desc": "The service loads the new cluster TLS certificate and starts sending heartbeats. The node appears in the Peers table within 30 seconds.", + "apiUnreachable": "Cluster API not reachable", + "tokenLabel": "Token", + "caFingerprintLabel": "CA fingerprint", "driftBanner": "Config drift detected", "driftBannerDesc": "One or more peers have a different config hash than this node. Either changes are still in the outbox or a peer was edited directly in the DB. Wait for the outbox to drain or run diagnostics.", "col": { @@ -1069,6 +1081,7 @@ "download": "Download", "copy": "Copy", "copied": "Copied", + "retry": "Retry", "close": "Close", "refresh": "Refresh", "up": "UP", diff --git a/management-ui/src/pages/Cluster/index.tsx b/management-ui/src/pages/Cluster/index.tsx index e3e8607..e439d10 100644 --- a/management-ui/src/pages/Cluster/index.tsx +++ b/management-ui/src/pages/Cluster/index.tsx @@ -1,6 +1,6 @@ -import { Alert, Button, Card, Descriptions, Input, Modal, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd' +import { Alert, Button, Card, Descriptions, Input, 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 { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,7 +9,9 @@ import apiClient, { isEnvelope } from '../../api/client' import PageHeader from '../../components/PageHeader' import { useAuthStore } from '../../stores/auth' -const { Text } = Typography +const { Text, Paragraph } = Typography + +const INSTALL_ONELINER = 'curl -fsSL https://get.netcell-edgeguard.de | sudo bash' interface HANode { id: string @@ -102,9 +104,6 @@ function lastSeenRelative(iso: string | null | undefined, now: number): string { 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(() => { @@ -114,24 +113,110 @@ function useTickingNow(intervalMs = 1000): number { return now } +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` +} + +function CertExpiry({ days, until }: { days: number; until: string }) { + const { t } = useTranslation() + const color = days < 0 ? 'red' : days < 30 ? 'red' : days < 90 ? 'orange' : 'green' + const label = days < 0 + ? t('cluster.certExpiredDaysAgo', { n: -days }) + : t('cluster.certDaysRemaining', { n: days }) + return ( + + {label} + + ) +} + +// Copyable code block — monospace TextArea + copy button below +function CopyCode({ value }: { value: string }) { + const { t } = useTranslation() + return ( +
+ + +
+ ) +} + +// Numbered step row inside the "Add node" card +function StepRow({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { + return ( +
+
+ {n} +
+
+ {title} + {children} +
+
+ ) +} + +function StepRowLast({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { + return ( +
+
+ {n} +
+
+ {title} + {children} +
+
+ ) +} + export default function ClusterPage() { const { t } = useTranslation() const now = useTickingNow() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' + const qc = useQueryClient() - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, error } = 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, + retry: 1, }) - // 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 () => { @@ -140,6 +225,7 @@ export default function ClusterPage() { return payload?.nodes ?? [] }, refetchInterval: 60_000, + retry: 1, }) const certStatus = useQuery({ @@ -149,8 +235,9 @@ export default function ClusterPage() { return isEnvelope(r.data) ? (r.data.data as CertStatus) : null }, refetchInterval: 5 * 60_000, + retry: 1, }) - const qc = useQueryClient() + const removePeer = useMutation({ mutationFn: async (id: string) => apiClient.delete(`/cluster/nodes/${id}`), onSuccess: () => { @@ -169,31 +256,20 @@ export default function ClusterPage() { message.success(t('cluster.certRenewedRestartHint')) void certStatus.refetch() }, - onError: (e: Error) => { - message.error(t('cluster.certRenewFailed') + ': ' + e.message) - }, + 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')) - }, + onSuccess: (tok) => { setJoinToken(tok) }, + onError: () => message.error(t('cluster.joinTokenFailed')), }) - if (isLoading) return - if (!data) return null - - const primaryFqdn = data.local_node?.fqdn ?? '' + const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname const joinCmd = joinToken ? `sudo edgeguard-ctl cluster-join ${primaryFqdn} \\\n --token ${joinToken.token}` : '' @@ -212,15 +288,19 @@ export default function ClusterPage() { title: t('cluster.col.status'), dataIndex: 'status', width: 110, render: (s: HANode['status']) => statusTag(s, t), }, - { 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.role'), dataIndex: 'role', width: 110, + render: (v: string) => {v}, + }, + { + title: t('cluster.col.version'), dataIndex: 'version', width: 100, + render: (v?: string | null) => v ? {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 localHash = data?.local_node?.config_hash const drifts = localHash && v !== localHash return ( @@ -230,23 +310,20 @@ export default function ClusterPage() { ) }, }, - { 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, + title: t('common.actions'), key: 'actions', width: 120, render: (_, r) => ( isViewer ? ( @@ -281,30 +358,45 @@ export default function ClusterPage() { } title={t('cluster.title')} - subtitle={t('cluster.intro', { count: 1 + data.peers.length })} + subtitle={t('cluster.intro', { count: 1 + (data?.peers.length ?? 0) })} extra={ - - - - - {data.mode === 'cluster' ? t('cluster.modeCluster') : t('cluster.modeSingle')} - - - {t(`cluster.health.${data.health}`)} - + {data && ( + <> + + {data.mode === 'cluster' ? t('cluster.modeCluster') : t('cluster.modeSingle')} + + + {t(`cluster.health.${data.health}`)} + + + )} } /> - {data.drift_found && ( + {/* ── Error banner ──────────────────────────────────────── */} + {isError && ( + } + onClick={() => void qc.invalidateQueries({ queryKey: ['cluster'] })} + > + {t('common.retry')} + + } + /> + )} + + {/* ── Config drift warning ──────────────────────────────── */} + {data?.drift_found && ( )} - {data.mode === 'single-node' && ( - - )} + {/* ── Add node wizard ───────────────────────────────────── */} + + + {t('cluster.step1Desc')} + + - - {data.local_node ? ( - - - - {data.local_node.fqdn} - - {data.local_node.id} + + {t('cluster.step2Desc')} + + + + + + + {joinToken ? ( + + + + + + {joinToken.token} + + + {joinToken.ca_fingerprint && ( + + {joinToken.ca_fingerprint} + + )} + +
+ + {t('cluster.joinCmdLabel')} - - - - {statusTag(data.local_node.status, t)} - - - - {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')} - )} + +
+
+ ) : ( + {t('cluster.step3NoToken')} + )} +
+ + + + + {t('cluster.step4Desc')} + +
- {(certStatus.data?.has_ca || certStatus.data?.has_peer) && ( - + + + ) : data ? ( + + {data.local_node ? ( + + + + {data.local_node.fqdn} + + {data.local_node.id} + + + + + {statusTag(data.local_node.status, t)} + + + + {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 || '—'} + + + ) : ( + + )} + + ) : null} + + {/* ── Cluster-TLS Zertifikate ───────────────────────────── */} + {certStatus.data?.has_ca && ( + @@ -392,7 +554,7 @@ export default function ClusterPage() { ) - )} + } > {certStatus.data.ca && ( @@ -419,8 +581,13 @@ export default function ClusterPage() { )} - {data.peers.length > 0 && ( - + {/* ── Peers ─────────────────────────────────────────────── */} + {data && 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). */} + {/* ── Per-Node Resources ────────────────────────────────── */} 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)} @@ -465,19 +630,19 @@ export default function ClusterPage() { : , }, { - title: t('cluster.col.mem'), key: 'mem', width: 110, + title: t('cluster.col.mem'), key: 'mem', width: 90, render: (_, r) => r.ok && r.data ? {r.data.mem_used_pct.toFixed(0)}% : , }, { - title: t('cluster.col.disk'), key: 'disk', width: 110, + title: t('cluster.col.disk'), key: 'disk', width: 90, render: (_, r) => r.ok && r.data ? {r.data.disk_used_pct.toFixed(0)}% : , }, { - title: t('cluster.col.conntrack'), key: 'ct', width: 130, + title: t('cluster.col.conntrack'), key: 'ct', width: 140, render: (_, r) => r.ok && r.data ? {r.data.conntrack_count}/{r.data.conntrack_max} @@ -485,7 +650,7 @@ export default function ClusterPage() { : , }, { - title: t('cluster.col.uptime'), key: 'up', width: 100, + title: t('cluster.col.uptime'), key: 'up', width: 90, render: (_, r) => r.ok && r.data ? {formatUptime(r.data.uptime_sec)} : , @@ -499,93 +664,6 @@ export default function ClusterPage() { ]} /> - - 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` -}