}
title={t('cluster.title')}
subtitle={t('cluster.intro', { count: 1 + data.peers.length })}
extra={
}
loading={generateToken.isPending}
onClick={() => generateToken.mutate()}
>
{t('cluster.generateJoinToken')}
{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()}
>
{t('cluster.renewSelfBtn')}
)}
>
{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={ setJoinTokenOpen(false)}>{t('common.close')} }
width={720}
>
{joinToken ? (
{joinToken.ca_fingerprint}
{t('cluster.joinCmdLabel')}
}
size="small"
style={{ marginTop: 6 }}
onClick={() => {
void navigator.clipboard.writeText(joinCmd)
message.success(t('common.copied'))
}}
>
{t('common.copy')}
) : 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`
}