feat(cluster): (c) Phase-3 MVP — stable node-id + self-register + Cluster-Page
Minimal-Slice für Phase-3-Cluster: * internal/cluster/node_id.go — stable UUID 'n-<16hex>' in /var/lib/edgeguard/node-id, idempotent über reboots. * internal/cluster/store.go — ha_nodes-Repo (List/Get/UpsertSelf) via pgxpool. EnsureSelfRegistered upsertet die lokale Row beim Boot mit FQDN aus setup.json. * internal/handlers/cluster.go — GET /api/v1/cluster/nodes liefert alle ha_nodes plus local_id (für UI-Highlighting). * main.go: nach DB-Pool-Open wird EnsureSelfRegistered (nur wenn setup.completed) ausgeführt, ClusterHandler registriert. * management-ui/src/pages/Cluster/index.tsx — Tabelle mit Node-ID, FQDN, Rolle, Beitrittszeit; eigene Node mit "diese Node"-Tag markiert. Sidebar-Eintrag + i18n de/en. Bewusst NICHT in dieser Runde: cluster-init/cluster-join CLIs, KeyDB Active-Active config-gen, PG streaming replication, mTLS zwischen Peers, License-Leader-Election. Diese kommen mit dem ersten echten Multi-Node-Test (Phase 3.1) — sonst Code ohne Smoke-Möglichkeit. End-to-end-Smoke: setup → restart → ha_nodes hat 1 Row mit fqdn=eg.example.com, /cluster/nodes liefert sie korrekt mit local_id-Markierung. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
74
management-ui/src/pages/Cluster/index.tsx
Normal file
74
management-ui/src/pages/Cluster/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Card, Spin, Table, Tag, Typography } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
interface HANode {
|
||||
id: string
|
||||
name: string
|
||||
fqdn: string
|
||||
api_url: string
|
||||
public_ip?: string | null
|
||||
internal_ip?: string | null
|
||||
role: string
|
||||
last_seen?: string | null
|
||||
joined_at: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ClusterPayload {
|
||||
nodes: HANode[]
|
||||
local_id: string
|
||||
}
|
||||
|
||||
export default function ClusterPage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['cluster', 'nodes'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/cluster/nodes')
|
||||
if (isEnvelope(r.data)) return r.data.data as ClusterPayload
|
||||
return null
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
if (isLoading) return <Spin />
|
||||
|
||||
const columns: ColumnsType<HANode> = [
|
||||
{
|
||||
title: t('cluster.id'), dataIndex: 'id', key: 'id',
|
||||
render: (id: string) => (
|
||||
<span>
|
||||
<code>{id}</code>{' '}
|
||||
{id === data?.local_id && <Tag color="blue">{t('cluster.self')}</Tag>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: t('cluster.fqdn'), dataIndex: 'fqdn', key: 'fqdn' },
|
||||
{ title: t('cluster.role'), dataIndex: 'role', key: 'role' },
|
||||
{ title: t('cluster.joinedAt'), dataIndex: 'joined_at', key: 'joined_at',
|
||||
render: (s: string) => new Date(s).toLocaleString() },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('cluster.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('cluster.intro', { count: data?.nodes.length ?? 0 })}
|
||||
</Typography.Paragraph>
|
||||
<Card>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data?.nodes ?? []}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user