Files
edgeguard-native/management-ui/src/pages/Cluster/index.tsx
Debian e18037375f fix(i18n): relative-time strings vollständig lokalisiert
relativeTime() in Dashboard, relTime() in WireGuard Clients+Servers und
CertExpiry() im Cluster-Page haben bisher hardcodiertes Deutsch
(„vor 5s", „abgelaufen vor X Tagen") gerendert, unabhängig von der
gewählten UI-Sprache.

Neu: gemeinsame i18n-Keys common.relTime.{Xs,Xm,Xh,Xd,never} in EN+DE,
sowie cluster.{certDaysRemaining,certExpiredDaysAgo}. Alle Helper-
Funktionen nehmen jetzt t() als Parameter und sind damit sprachunabhängig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:02:27 +02:00

573 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <Tag color="green">online</Tag>
case 'offline': return <Tag color="red">offline</Tag>
case 'joining': return <Tag color="blue">joining</Tag>
case 'leaving': return <Tag color="orange">leaving</Tag>
default: return <Tag>unknown</Tag>
}
}
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<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'))
},
})
if (isLoading) return <Spin />
if (!data) return null
const primaryFqdn = data.local_node?.fqdn ?? '<primary-fqdn>'
const joinCmd = joinToken
? `sudo edgeguard-ctl cluster-join ${primaryFqdn} \\\n --token ${joinToken.token}`
: ''
const peerColumns: ColumnsType<HANode> = [
{
title: t('cluster.col.node'), key: 'node',
render: (_, r) => (
<div>
<div><Text strong>{r.fqdn}</Text></div>
<div><Text type="secondary" style={{ fontFamily: 'monospace', fontSize: 11 }}>{r.id}</Text></div>
</div>
),
},
{
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) => <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.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 drifts = localHash && v !== localHash
return (
<Space size={4}>
<Text code style={{ fontSize: 11 }}>{v.slice(0, 12)}</Text>
{drifts && <Tag color="red">{t('cluster.drift')}</Tag>}
</Space>
)
},
},
{ 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}>
<Text type={stale ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>{rel}</Text>
</Tooltip>
)
},
},
{
title: t('common.actions'), key: 'actions', width: 110,
render: (_, r) => (
<Popconfirm
title={t('cluster.removePeerConfirmTitle')}
description={t('cluster.removePeerConfirmDesc', { fqdn: r.fqdn })}
okText={t('common.yes')}
cancelText={t('common.no')}
okButtonProps={{ danger: true }}
onConfirm={() => removePeer.mutate(r.id)}
>
<Button
type="text" size="small" danger
icon={<DeleteOutlined />}
loading={removePeer.isPending && removePeer.variables === r.id}
>
{t('cluster.removePeerBtn')}
</Button>
</Popconfirm>
),
},
]
return (
<div>
<PageHeader
icon={<ApartmentOutlined />}
title={t('cluster.title')}
subtitle={t('cluster.intro', { count: 1 + data.peers.length })}
extra={
<Space>
<Button
icon={<KeyOutlined />}
loading={generateToken.isPending}
onClick={() => generateToken.mutate()}
>
{t('cluster.generateJoinToken')}
</Button>
<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 && (
<Alert
type="warning"
showIcon
banner
className="mb-16"
message={t('cluster.driftBanner')}
description={t('cluster.driftBannerDesc')}
/>
)}
{data.mode === 'single-node' && (
<Alert
type="info"
showIcon
className="mb-16"
message={t('cluster.singleNodeTitle')}
description={t('cluster.singleNodeDesc')}
/>
)}
<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)}
</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>
)}
</Card>
{(certStatus.data?.has_ca || certStatus.data?.has_peer) && (
<Card size="small" title={t('cluster.certCardTitle')} className="mb-16"
extra={certStatus.data?.has_ca && (
<Popconfirm
title={t('cluster.renewSelfConfirm')}
okText={t('common.yes')}
cancelText={t('common.no')}
onConfirm={() => renewSelf.mutate()}
>
<Button size="small" loading={renewSelf.isPending}>
{t('cluster.renewSelfBtn')}
</Button>
</Popconfirm>
)}
>
<Descriptions size="small" column={2} bordered>
{certStatus.data.ca && (
<>
<Descriptions.Item label={t('cluster.certCALabel')}>
<Text>{certStatus.data.ca.common_name}</Text>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.certExpiry')}>
<CertExpiry days={certStatus.data.ca.days_remaining} until={certStatus.data.ca.not_after} />
</Descriptions.Item>
</>
)}
{certStatus.data.peer && (
<>
<Descriptions.Item label={t('cluster.certPeerLabel')}>
<Text>{certStatus.data.peer.common_name}</Text>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.certExpiry')}>
<CertExpiry days={certStatus.data.peer.days_remaining} until={certStatus.data.peer.not_after} />
</Descriptions.Item>
</>
)}
</Descriptions>
</Card>
)}
{data.peers.length > 0 && (
<Card size="small" title={t('cluster.peersTitle', { count: data.peers.length })}>
<Table
size="small"
rowKey="id"
dataSource={data.peers}
columns={peerColumns}
pagination={false}
/>
</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). */}
<Card
size="small"
title={t('cluster.loadTitle')}
className="mt-16"
loading={loadQuery.isLoading}
>
<Table<PeerLoadResult>
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) => (
<Space>
<Text strong>{r.fqdn || r.node_id}</Text>
{!r.ok && <Tag color="red">{r.error || 'error'}</Tag>}
</Space>
),
},
{
title: t('cluster.col.load'), key: 'load', width: 110,
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)}
</Text>
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.mem'), key: 'mem', width: 110,
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,
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,
render: (_, r) => r.ok && r.data
? <Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
{r.data.conntrack_count}/{r.data.conntrack_max}
</Text>
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.uptime'), key: 'up', width: 100,
render: (_, r) => r.ok && r.data
? <Text type="secondary" style={{ fontSize: 12 }}>{formatUptime(r.data.uptime_sec)}</Text>
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.fetchMs'), key: 'ms', width: 80,
render: (_, r) => (
<Text type="secondary" style={{ fontSize: 11 }}>{r.duration_ms}ms</Text>
),
},
]}
/>
</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`
}