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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user