import { Alert, Card, Descriptions, Space, Spin, Table, Tag, Typography } from 'antd' import type { ColumnsType } from 'antd/es/table' import { ApartmentOutlined } from '@ant-design/icons' import { useQuery } from '@tanstack/react-query' 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 } function statusTag(s: HANode['status']) { switch (s) { case 'online': return online case 'offline': return offline case 'joining': return joining case 'leaving': return leaving default: return unknown } } function lastSeenRelative(iso?: string | null): string { if (!iso) return '—' const ms = Date.now() - new Date(iso).getTime() 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` } export default function ClusterPage() { const { t } = useTranslation() 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, }) if (isLoading) return if (!data) return null const peerColumns: ColumnsType = [ { title: t('cluster.col.node'), key: 'node', render: (_, r) => (
{r.fqdn}
{r.id}
), }, { 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) => {v} }, { title: t('cluster.col.apiUrl'), dataIndex: 'api_url', width: 240, render: (v: string) => {v} }, { title: t('cluster.col.configHash'), dataIndex: 'config_hash', width: 160, render: (v: string | null | undefined) => { if (!v) return const localHash = data.local_node?.config_hash const drifts = localHash && v !== localHash return ( {v.slice(0, 12)}… {drifts && {t('cluster.drift')}} ) }, }, { title: t('cluster.col.version'), dataIndex: 'version', width: 100, render: (v?: string | null) => v ? {v} : }, { title: t('cluster.col.lastSeen'), dataIndex: 'last_seen', width: 100, render: (v?: string | null) => ( {lastSeenRelative(v)} ), }, ] return (
} title={t('cluster.title')} subtitle={t('cluster.intro', { count: 1 + data.peers.length })} extra={ {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} : '—'} {data.local_node.mgmt_ip || '—'} {data.local_node.api_url} {data.local_node.config_hash || '—'} ) : ( {t('cluster.noSelf')} )} {data.peers.length > 0 && ( )} ) }