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,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`
}