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:
@@ -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`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user