feat(cluster): Phase 3 Foundation — node.conf + ha_nodes-Drift + UI

Code-Vorbereitung für Multi-Node, ohne dass eine zweite Box nötig ist.
Single-Node-Mode bleibt der Default; alles existiert und wird sichtbar,
sobald ein 2. Knoten joined (Phase 3.2 später).

Migration 0020:
  ha_nodes += version (edgeguard-api-Version)
              config_hash (drift-Detection-Hash)
              mgmt_ip (Management-IP, niemals VIP)
              status (online|offline|joining|leaving|unknown)

internal/cluster/local_config.go:
  /etc/edgeguard/node.conf — INI-style, node-lokale Identität:
  NODE_ID, HOSTNAME, MGMT_IP, ROLE, PEER_HOSTS. NIEMALS zwischen
  Cluster-Peers replizieren. LoadLocalConfig / SaveLocalConfig /
  EnsureLocalConfig (auto-Generierung beim ersten Boot).
  MgmtIP-Default = firstNonLoopbackIPv4(); Operator kann
  überschreiben (mehrere Interfaces).

internal/cluster/store.go:
  - HANode-Model um die 4 neuen Felder erweitert
  - UpsertSelf nimmt jetzt mgmt_ip/version/config_hash/status, COALESCE
    erhält werte wenn der Caller sie nicht setzt
  - EnsureSelfRegistered-Signatur: + role + version-Argument

internal/handlers/cluster.go:
  GET /api/v1/cluster/status — strukturierter Endpoint:
    {local_id, local_node, peers[], mode, health, drift_found, updated_at}
  GET /api/v1/cluster/nodes bleibt für Tools.

UI (pages/Cluster):
  - Header zeigt Mode-Tag (Single-Node / Cluster) + Health-Tag (OK /
    degraded / split-brain)
  - Self-Card: Descriptions mit FQDN, Node-ID, Status, Role, Version,
    MGMT-IP, API-URL, Config-Hash
  - Peers-Tabelle nur wenn vorhanden, mit "drift"-Marker pro Row
  - Drift-Alert-Banner wenn ein Peer einen anderen config_hash hat
  - Single-Node-Mode Hinweis-Alert ("cluster-join kommt in 3.2")

postinst: leeres /etc/edgeguard/node.conf wird angelegt (chown
edgeguard); API auto-befüllt beim ersten boot.

main.go ruft EnsureLocalConfig + EnsureSelfRegistered mit version.

Verifiziert auf der Box (1.0.70):
  - /etc/edgeguard/node.conf hat NODE_ID, HOSTNAME, MGMT_IP=89.163.205.6,
    ROLE=primary
  - ha_nodes-Row: status=online, version=1.0.70, mgmt_ip=89.163.205.6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-13 08:27:00 +02:00
parent df77b814ff
commit ea7c356455
14 changed files with 618 additions and 78 deletions

View File

@@ -81,7 +81,7 @@ const NAV: NavSection[] = [
},
]
const VERSION = '1.0.69'
const VERSION = '1.0.70'
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent

View File

@@ -267,12 +267,37 @@
},
"cluster": {
"title": "Cluster",
"intro": "{{count}} Node(s) registriert. Multi-Node-Cluster (KeyDB Active-Active + PG Streaming Replication) folgt in einem späteren Release.",
"intro": "{{count}} Node(s) registriert. Multi-Node (KeyDB-AA + PG-Streaming-Replication + Leader-Election) folgt schrittweise.",
"id": "Node-ID",
"fqdn": "FQDN",
"role": "Rolle",
"joinedAt": "Beigetreten",
"self": "diese Node"
"self": "diese Node",
"drift": "Drift",
"modeSingle": "Single-Node",
"modeCluster": "Cluster",
"health": {
"ok": "OK",
"degraded": "degraded",
"split-brain": "split-brain"
},
"selfTitle": "Dieser Knoten",
"noSelf": "Selbst-Registrierung in ha_nodes fehlgeschlagen — Setup-Wizard durchlaufen.",
"peersTitle": "Peers ({{count}})",
"singleNodeTitle": "Single-Node-Modus",
"singleNodeDesc": "Nur diese Box bekannt. Zusätzlicher Knoten? `edgeguard-ctl cluster-join` (kommt in Phase 3.2).",
"driftBanner": "Config-Drift erkannt",
"driftBannerDesc": "Ein oder mehrere Peers haben einen anderen Config-Hash als dieser Node. Entweder stehen noch Änderungen in der Outbox, oder auf einem Peer wurde direkt in der DB editiert. Warte bis die Outbox leer ist oder starte Diagnostics.",
"col": {
"node": "Knoten",
"status": "Status",
"role": "Rolle",
"apiUrl": "API-URL",
"configHash": "Config-Hash",
"version": "Version",
"lastSeen": "Last seen",
"mgmtIp": "MGMT-IP"
}
},
"ssl": {
"title": "SSL-Zertifikate",

View File

@@ -267,12 +267,37 @@
},
"cluster": {
"title": "Cluster",
"intro": "{{count}} node(s) registered. Multi-node cluster (KeyDB Active-Active + PG streaming replication) coming in a later release.",
"intro": "{{count}} node(s) registered. Multi-node (KeyDB-AA + PG streaming replication + leader election) lands in stages.",
"id": "Node ID",
"fqdn": "FQDN",
"role": "Role",
"joinedAt": "Joined",
"self": "this node"
"self": "this node",
"drift": "Drift",
"modeSingle": "Single node",
"modeCluster": "Cluster",
"health": {
"ok": "OK",
"degraded": "degraded",
"split-brain": "split-brain"
},
"selfTitle": "This node",
"noSelf": "Self-registration in ha_nodes failed — run the setup wizard.",
"peersTitle": "Peers ({{count}})",
"singleNodeTitle": "Single-node mode",
"singleNodeDesc": "Only this box is known. To add another node: `edgeguard-ctl cluster-join` (coming in phase 3.2).",
"driftBanner": "Config drift detected",
"driftBannerDesc": "One or more peers have a different config hash than this node. Either changes are still in the outbox or a peer was edited directly in the DB. Wait for the outbox to drain or run diagnostics.",
"col": {
"node": "Node",
"status": "Status",
"role": "Role",
"apiUrl": "API URL",
"configHash": "Config hash",
"version": "Version",
"lastSeen": "Last seen",
"mgmtIp": "MGMT IP"
}
},
"ssl": {
"title": "SSL certificates",

View File

@@ -1,12 +1,13 @@
import { Card, Spin, Tag } from 'antd'
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 DataTable from '../../components/DataTable'
import PageHeader from '../../components/PageHeader'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
const { Text } = Typography
interface HANode {
id: string
@@ -15,47 +16,98 @@ interface HANode {
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
created_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 ClusterPayload {
nodes: HANode[]
local_id: string
function statusTag(s: HANode['status']) {
switch (s) {
case 'online': return <Tag color="green">online</Tag>
case 'offline': return <Tag color="red">offline</Tag>
case 'joining': return <Tag color="blue">joining</Tag>
case 'leaving': return <Tag color="orange">leaving</Tag>
default: return <Tag>unknown</Tag>
}
}
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', 'nodes'],
queryKey: ['cluster', 'status'],
queryFn: async () => {
const r = await apiClient.get('/cluster/nodes')
if (isEnvelope(r.data)) return r.data.data as ClusterPayload
return null
const r = await apiClient.get('/cluster/status')
return isEnvelope(r.data) ? (r.data.data as ClusterStatus) : null
},
refetchInterval: 30_000,
})
if (isLoading) return <Spin />
if (!data) return null
const columns: ColumnsType<HANode> = [
const peerColumns: 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.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),
},
{ 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: 100,
render: (v?: string | null) => (
<Text type="secondary" style={{ fontSize: 12 }}>{lastSeenRelative(v)}</Text>
),
},
{ 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 (
@@ -63,11 +115,92 @@ export default function ClusterPage() {
<PageHeader
icon={<ApartmentOutlined />}
title={t('cluster.title')}
subtitle={t('cluster.intro', { count: data?.nodes.length ?? 0 })}
subtitle={t('cluster.intro', { count: 1 + data.peers.length })}
extra={
<Space>
<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>
}
/>
<Card size="small">
<DataTable rowKey="id" columns={columns} dataSource={data?.nodes ?? []} />
{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)}
</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.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>
{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>
)}
</div>
)
}