feat(cluster): Seite komplett neu — Blank-Page-Fix + Node-Join-Wizard

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 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 15:17:12 +02:00
parent 31d3485a2f
commit 33f6fef009
7 changed files with 320 additions and 216 deletions

View File

@@ -1 +1 @@
1.1.145
1.1.146

View File

@@ -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")

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<Tooltip title={new Date(until).toLocaleString()}>
<Tag color={color}>{label}</Tag>
</Tooltip>
)
}
// Copyable code block — monospace TextArea + copy button below
function CopyCode({ value }: { value: string }) {
const { t } = useTranslation()
return (
<div>
<Input.TextArea
value={value}
readOnly
autoSize={{ minRows: 1, maxRows: 5 }}
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
<Button
icon={<CopyOutlined />}
size="small"
className="mt-8"
onClick={() => {
void navigator.clipboard.writeText(value)
void message.success(t('common.copied'))
}}
>
{t('common.copy')}
</Button>
</div>
)
}
// Numbered step row inside the "Add node" card
function StepRow({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', gap: 16, paddingBottom: 20, marginBottom: 20, borderBottom: '1px solid #f0f0f0' }}>
<div style={{
flexShrink: 0, width: 28, height: 28, borderRadius: '50%',
background: '#1677ff', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: 13, marginTop: 2,
}}>
{n}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>{title}</Text>
{children}
</div>
</div>
)
}
function StepRowLast({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', gap: 16 }}>
<div style={{
flexShrink: 0, width: 28, height: 28, borderRadius: '50%',
background: '#52c41a', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: 13, marginTop: 2,
}}>
{n}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>{title}</Text>
{children}
</div>
</div>
)
}
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<JoinTokenResponse | null>(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 <Spin />
if (!data) return null
const primaryFqdn = data.local_node?.fqdn ?? '<primary-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) => <Tag color={v === 'primary' ? 'gold' : 'default'}>{v}</Tag> },
{ title: t('cluster.col.apiUrl'), dataIndex: 'api_url', width: 240,
render: (v: string) => <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>{v}</Text> },
{
title: t('cluster.col.role'), dataIndex: 'role', width: 110,
render: (v: string) => <Tag color={v === 'primary' ? 'gold' : 'default'}>{v}</Tag>,
},
{
title: t('cluster.col.version'), dataIndex: 'version', width: 100,
render: (v?: string | null) => v ? <Tag>{v}</Tag> : <Text type="secondary"></Text>,
},
{
title: t('cluster.col.configHash'), dataIndex: 'config_hash', width: 160,
render: (v: string | null | undefined) => {
if (!v) return <Text type="secondary"></Text>
const localHash = data.local_node?.config_hash
const localHash = data?.local_node?.config_hash
const drifts = localHash && v !== localHash
return (
<Space size={4}>
@@ -230,23 +310,20 @@ export default function ClusterPage() {
)
},
},
{ title: t('cluster.col.version'), dataIndex: 'version', width: 100,
render: (v?: string | null) => v ? <Tag>{v}</Tag> : <Text type="secondary"></Text> },
{
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 (
<Tooltip title={tipText}>
<Tooltip title={v ? new Date(v).toLocaleString() : '—'}>
<Text type={stale ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>{rel}</Text>
</Tooltip>
)
},
},
{
title: t('common.actions'), key: 'actions', width: 110,
title: t('common.actions'), key: 'actions', width: 120,
render: (_, r) => (
isViewer ? (
<Tooltip title={t('auth.viewerBadge')}>
@@ -281,30 +358,45 @@ export default function ClusterPage() {
<PageHeader
icon={<ApartmentOutlined />}
title={t('cluster.title')}
subtitle={t('cluster.intro', { count: 1 + data.peers.length })}
subtitle={t('cluster.intro', { count: 1 + (data?.peers.length ?? 0) })}
extra={
<Space>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
icon={<KeyOutlined />}
loading={generateToken.isPending}
disabled={isViewer}
onClick={() => generateToken.mutate()}
>
{t('cluster.generateJoinToken')}
</Button>
</Tooltip>
<Tag color={data.mode === 'cluster' ? 'blue' : 'default'}>
{data.mode === 'cluster' ? t('cluster.modeCluster') : t('cluster.modeSingle')}
</Tag>
<Tag color={data.health === 'ok' ? 'green' : data.health === 'degraded' ? 'orange' : 'red'}>
{t(`cluster.health.${data.health}`)}
</Tag>
{data && (
<>
<Tag color={data.mode === 'cluster' ? 'blue' : 'default'}>
{data.mode === 'cluster' ? t('cluster.modeCluster') : t('cluster.modeSingle')}
</Tag>
<Tag color={data.health === 'ok' ? 'green' : data.health === 'degraded' ? 'orange' : 'red'}>
{t(`cluster.health.${data.health}`)}
</Tag>
</>
)}
</Space>
}
/>
{data.drift_found && (
{/* ── Error banner ──────────────────────────────────────── */}
{isError && (
<Alert
type="error"
showIcon
className="mb-16"
message={t('cluster.apiUnreachable')}
description={(error as Error)?.message ?? 'Unknown error'}
action={
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => void qc.invalidateQueries({ queryKey: ['cluster'] })}
>
{t('common.retry')}
</Button>
}
/>
)}
{/* ── Config drift warning ──────────────────────────────── */}
{data?.drift_found && (
<Alert
type="warning"
showIcon
@@ -315,67 +407,137 @@ export default function ClusterPage() {
/>
)}
{data.mode === 'single-node' && (
<Alert
type="info"
showIcon
className="mb-16"
message={t('cluster.singleNodeTitle')}
description={t('cluster.singleNodeDesc')}
/>
)}
{/* ── Add node wizard ───────────────────────────────────── */}
<Card title={t('cluster.addNodeTitle')} className="mb-16">
<StepRow n={1} title={t('cluster.step1Title')}>
<Paragraph type="secondary" style={{ marginBottom: 8 }}>{t('cluster.step1Desc')}</Paragraph>
<CopyCode value={INSTALL_ONELINER} />
</StepRow>
<Card size="small" title={t('cluster.selfTitle')} className="mb-16">
{data.local_node ? (
<Descriptions size="small" column={2} bordered>
<Descriptions.Item label={t('cluster.col.node')} span={2}>
<Space direction="vertical" size={2}>
<Text strong>{data.local_node.fqdn}</Text>
<Text type="secondary" style={{ fontFamily: 'monospace', fontSize: 11 }}>
{data.local_node.id}
<StepRow n={2} title={t('cluster.step2Title')}>
<Paragraph type="secondary" style={{ marginBottom: 8 }}>{t('cluster.step2Desc')}</Paragraph>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
type="primary"
icon={<KeyOutlined />}
loading={generateToken.isPending}
disabled={isViewer || (isError && !data)}
onClick={() => generateToken.mutate()}
>
{t('cluster.generateJoinToken')}
</Button>
</Tooltip>
</StepRow>
<StepRow n={3} title={t('cluster.step3Title')}>
{joinToken ? (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Alert
type="warning"
showIcon
message={t('cluster.joinTokenOneShot')}
description={t('cluster.joinTokenOneShotDesc', {
expires: new Date(joinToken.expires_at).toLocaleString(),
})}
/>
<Descriptions size="small" bordered column={1}>
<Descriptions.Item label={t('cluster.tokenLabel')}>
<Text style={{ fontFamily: 'monospace', fontSize: 11, wordBreak: 'break-all' }}>
{joinToken.token}
</Text>
</Descriptions.Item>
{joinToken.ca_fingerprint && (
<Descriptions.Item label={t('cluster.caFingerprintLabel')}>
<Text code style={{ fontSize: 11 }}>{joinToken.ca_fingerprint}</Text>
</Descriptions.Item>
)}
</Descriptions>
<div>
<Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
{t('cluster.joinCmdLabel')}
</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.status')}>
{statusTag(data.local_node.status, t)}
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.role')}>
<Tag color={data.local_node.role === 'primary' ? 'gold' : 'default'}>
{data.local_node.role}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.version')}>
{data.local_node.version ? <Tag>{data.local_node.version}</Tag> : '—'}
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.lastSeen')}>
<Tooltip title={data.local_node.last_seen ? new Date(data.local_node.last_seen).toLocaleString() : '—'}>
<Text type={data.local_node.status === 'online' ? 'secondary' : 'danger'} style={{ fontSize: 12 }}>
{lastSeenRelative(data.local_node.last_seen, now)}
</Text>
</Tooltip>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.mgmtIp')}>
<Text style={{ fontFamily: 'monospace' }}>
{data.local_node.mgmt_ip || '—'}
</Text>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.apiUrl')} span={2}>
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
{data.local_node.api_url}
</Text>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.configHash')} span={2}>
<Text code>{data.local_node.config_hash || '—'}</Text>
</Descriptions.Item>
</Descriptions>
) : (
<Text type="secondary">{t('cluster.noSelf')}</Text>
)}
<CopyCode value={joinCmd} />
</div>
</Space>
) : (
<Text type="secondary">{t('cluster.step3NoToken')}</Text>
)}
</StepRow>
<StepRowLast n={4} title={t('cluster.step4Title')}>
<CopyCode value="sudo systemctl restart edgeguard-api" />
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
{t('cluster.step4Desc')}
</Paragraph>
</StepRowLast>
</Card>
{(certStatus.data?.has_ca || certStatus.data?.has_peer) && (
<Card size="small" title={t('cluster.certCardTitle')} className="mb-16"
extra={certStatus.data?.has_ca && (
{/* ── Dieser Knoten ─────────────────────────────────────── */}
{isLoading ? (
<Card size="small" title={t('cluster.selfTitle')} className="mb-16">
<Spin />
</Card>
) : data ? (
<Card size="small" title={t('cluster.selfTitle')} className="mb-16">
{data.local_node ? (
<Descriptions size="small" column={2} bordered>
<Descriptions.Item label={t('cluster.col.node')} span={2}>
<Space direction="vertical" size={2}>
<Text strong>{data.local_node.fqdn}</Text>
<Text type="secondary" style={{ fontFamily: 'monospace', fontSize: 11 }}>
{data.local_node.id}
</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.status')}>
{statusTag(data.local_node.status, t)}
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.role')}>
<Tag color={data.local_node.role === 'primary' ? 'gold' : 'default'}>
{data.local_node.role}
</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.version')}>
{data.local_node.version ? <Tag>{data.local_node.version}</Tag> : '—'}
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.lastSeen')}>
<Tooltip title={data.local_node.last_seen ? new Date(data.local_node.last_seen).toLocaleString() : '—'}>
<Text type="secondary" style={{ fontSize: 12 }}>
{lastSeenRelative(data.local_node.last_seen, now)}
</Text>
</Tooltip>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.mgmtIp')}>
<Text style={{ fontFamily: 'monospace' }}>
{data.local_node.mgmt_ip || '—'}
</Text>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.apiUrl')} span={2}>
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
{data.local_node.api_url}
</Text>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.configHash')} span={2}>
<Text code>{data.local_node.config_hash || '—'}</Text>
</Descriptions.Item>
</Descriptions>
) : (
<Alert
type="warning"
showIcon
message={t('cluster.noSelf')}
/>
)}
</Card>
) : null}
{/* ── Cluster-TLS Zertifikate ───────────────────────────── */}
{certStatus.data?.has_ca && (
<Card
size="small"
title={t('cluster.certCardTitle')}
className="mb-16"
extra={
isViewer ? (
<Tooltip title={t('auth.viewerBadge')}>
<Button size="small" disabled>{t('cluster.renewSelfBtn')}</Button>
@@ -392,7 +554,7 @@ export default function ClusterPage() {
</Button>
</Popconfirm>
)
)}
}
>
<Descriptions size="small" column={2} bordered>
{certStatus.data.ca && (
@@ -419,8 +581,13 @@ export default function ClusterPage() {
</Card>
)}
{data.peers.length > 0 && (
<Card size="small" title={t('cluster.peersTitle', { count: data.peers.length })}>
{/* ── Peers ─────────────────────────────────────────────── */}
{data && data.peers.length > 0 && (
<Card
size="small"
title={t('cluster.peersTitle', { count: data.peers.length })}
className="mb-16"
>
<Table
size="small"
rowKey="id"
@@ -431,9 +598,7 @@ export default function ClusterPage() {
</Card>
)}
{/* 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 ────────────────────────────────── */}
<Card
size="small"
title={t('cluster.loadTitle')}
@@ -457,7 +622,7 @@ export default function ClusterPage() {
),
},
{
title: t('cluster.col.load'), key: 'load', width: 110,
title: t('cluster.col.load'), key: 'load', width: 140,
render: (_, r) => r.ok && r.data
? <Text style={{ fontFamily: 'monospace' }}>
{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() {
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.mem'), key: 'mem', width: 110,
title: t('cluster.col.mem'), key: 'mem', width: 90,
render: (_, r) => r.ok && r.data
? <Text>{r.data.mem_used_pct.toFixed(0)}%</Text>
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.disk'), key: 'disk', width: 110,
title: t('cluster.col.disk'), key: 'disk', width: 90,
render: (_, r) => r.ok && r.data
? <Text>{r.data.disk_used_pct.toFixed(0)}%</Text>
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.conntrack'), key: 'ct', width: 130,
title: t('cluster.col.conntrack'), key: 'ct', width: 140,
render: (_, r) => r.ok && r.data
? <Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
{r.data.conntrack_count}/{r.data.conntrack_max}
@@ -485,7 +650,7 @@ export default function ClusterPage() {
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.uptime'), key: 'up', width: 100,
title: t('cluster.col.uptime'), key: 'up', width: 90,
render: (_, r) => r.ok && r.data
? <Text type="secondary" style={{ fontSize: 12 }}>{formatUptime(r.data.uptime_sec)}</Text>
: <Text type="secondary"></Text>,
@@ -499,93 +664,6 @@ export default function ClusterPage() {
]}
/>
</Card>
<Modal
title={t('cluster.joinTokenTitle')}
open={joinTokenOpen}
onCancel={() => setJoinTokenOpen(false)}
footer={<Button onClick={() => setJoinTokenOpen(false)}>{t('common.close')}</Button>}
width={720}
>
{joinToken ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message={t('cluster.joinTokenOneShot')}
description={t('cluster.joinTokenOneShotDesc', {
expires: new Date(joinToken.expires_at).toLocaleString(),
})}
/>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="Token">
<Input.TextArea
value={joinToken.token}
readOnly
autoSize={{ minRows: 2, maxRows: 4 }}
style={{ fontFamily: 'monospace', fontSize: 11 }}
/>
</Descriptions.Item>
<Descriptions.Item label="CA-Fingerprint">
<Text code>{joinToken.ca_fingerprint}</Text>
</Descriptions.Item>
</Descriptions>
<div>
<Text strong>{t('cluster.joinCmdLabel')}</Text>
<Input.TextArea
value={joinCmd}
readOnly
autoSize={{ minRows: 2, maxRows: 4 }}
style={{ fontFamily: 'monospace', fontSize: 12, marginTop: 6 }}
/>
<Button
icon={<CopyOutlined />}
size="small"
style={{ marginTop: 6 }}
onClick={() => {
void navigator.clipboard.writeText(joinCmd)
message.success(t('common.copied'))
}}
>
{t('common.copy')}
</Button>
</div>
</Space>
) : null}
</Modal>
</div>
)
}
// CertExpiry rendert "<n> 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 (
<Tooltip title={new Date(until).toLocaleString()}>
<Tag color={color === '#52c41a' ? 'green' : color === '#d4651a' ? 'orange' : 'red'}>
{label}
</Tag>
</Tooltip>
)
}
// 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`
}