Settings: HAProxy-Reload, Render-Configs, Backup-Now, Service-Restart, E-Mail-Save, Maintenance-Toggle, Backup-/Audit-Retention, Auto-Update- und IPv6-Toggle disabled für Viewer (Passwort-Änderung bleibt aktiv). Cluster: Join-Token-Generation, Peer-Removal, mTLS-Cert-Renew disabled. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
592 lines
21 KiB
TypeScript
592 lines
21 KiB
TypeScript
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'
|
||
import { useAuthStore } from '../../stores/auth'
|
||
|
||
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'], t: (k: string) => string) {
|
||
switch (s) {
|
||
case 'online': return <Tag color="green">{t('cluster.status.online')}</Tag>
|
||
case 'offline': return <Tag color="red">{t('cluster.status.offline')}</Tag>
|
||
case 'joining': return <Tag color="blue">{t('cluster.status.joining')}</Tag>
|
||
case 'leaving': return <Tag color="orange">{t('cluster.status.leaving')}</Tag>
|
||
default: return <Tag>{t('cluster.status.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 isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||
|
||
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, 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.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) => (
|
||
isViewer ? (
|
||
<Tooltip title={t('auth.viewerBadge')}>
|
||
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled>
|
||
{t('cluster.removePeerBtn')}
|
||
</Button>
|
||
</Tooltip>
|
||
) : (
|
||
<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>
|
||
<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>
|
||
</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, 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>
|
||
)}
|
||
</Card>
|
||
|
||
{(certStatus.data?.has_ca || certStatus.data?.has_peer) && (
|
||
<Card size="small" title={t('cluster.certCardTitle')} className="mb-16"
|
||
extra={certStatus.data?.has_ca && (
|
||
isViewer ? (
|
||
<Tooltip title={t('auth.viewerBadge')}>
|
||
<Button size="small" disabled>{t('cluster.renewSelfBtn')}</Button>
|
||
</Tooltip>
|
||
) : (
|
||
<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`
|
||
}
|