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:
Debian
2026-05-09 11:52:54 +02:00
parent 6525cb1a41
commit cb5691cf3c
10 changed files with 421 additions and 2 deletions

View File

@@ -16,6 +16,7 @@ const DashboardPage = lazy(() => import('./pages/Dashboard'))
const DomainsPage = lazy(() => import('./pages/Domains'))
const BackendsPage = lazy(() => import('./pages/Backends'))
const RoutingRulesPage = lazy(() => import('./pages/RoutingRules'))
const ClusterPage = lazy(() => import('./pages/Cluster'))
const SettingsPage = lazy(() => import('./pages/Settings'))
const queryClient = new QueryClient({
@@ -79,6 +80,7 @@ export default function App() {
<Route path="/domains" element={<DomainsPage />} />
<Route path="/backends" element={<BackendsPage />} />
<Route path="/routing-rules" element={<RoutingRulesPage />} />
<Route path="/cluster" element={<ClusterPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>

View File

@@ -1,4 +1,4 @@
import { BranchesOutlined, DashboardOutlined, DatabaseOutlined, GlobalOutlined, SettingOutlined } from '@ant-design/icons'
import { ApartmentOutlined, BranchesOutlined, DashboardOutlined, DatabaseOutlined, GlobalOutlined, SettingOutlined } from '@ant-design/icons'
import { Menu, Typography } from 'antd'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
@@ -13,6 +13,7 @@ export default function Sidebar() {
{ key: '/domains', icon: <GlobalOutlined />, label: t('nav.domains') },
{ key: '/backends', icon: <DatabaseOutlined />, label: t('nav.backends') },
{ key: '/routing-rules', icon: <BranchesOutlined />, label: t('nav.routing') },
{ key: '/cluster', icon: <ApartmentOutlined />, label: t('nav.cluster') },
{ key: '/settings', icon: <SettingOutlined />, label: t('nav.settings') },
]

View File

@@ -83,6 +83,15 @@
"selectBackend": "Backend wählen",
"deleteConfirm": "Diese Routing-Regel wirklich löschen?"
},
"cluster": {
"title": "Cluster",
"intro": "{{count}} Node(s) registriert. Multi-Node-Cluster (KeyDB Active-Active + PG Streaming Replication) folgt in einem späteren Release.",
"id": "Node-ID",
"fqdn": "FQDN",
"role": "Rolle",
"joinedAt": "Beigetreten",
"self": "diese Node"
},
"settings": {
"title": "Einstellungen",
"intro": "System-Information und Setup-Status. Bearbeitbare Werte folgen in einem späteren Release.",

View File

@@ -83,6 +83,15 @@
"selectBackend": "Select backend",
"deleteConfirm": "Really delete this routing rule?"
},
"cluster": {
"title": "Cluster",
"intro": "{{count}} node(s) registered. Multi-node cluster (KeyDB Active-Active + PG streaming replication) coming in a later release.",
"id": "Node ID",
"fqdn": "FQDN",
"role": "Role",
"joinedAt": "Joined",
"self": "this node"
},
"settings": {
"title": "Settings",
"intro": "System information and setup status. Editable values come in a later release.",

View 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>
)
}