Files
edgeguard-native/management-ui/src/pages/Cluster/index.tsx
Debian 8b0e8a8ab4 fix(rbac): Viewer-Rolle in Settings + Cluster — operative Buttons sperren
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>
2026-05-24 13:14:51 +02:00

592 lines
21 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'
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`
}