fix(ui): Cluster-Karte zeigte auf dem Standby beide Knoten als DB-Primary
Auf utm-2 trug die Cluster-Karte an BEIDEN Knoten "DB-Primary" — auf utm-1 dagegen korrekt "primary" und "peer". Es kann aber nur einen DB-Primary geben, also war mindestens eine der beiden Ansichten falsch. Ursache: ha_nodes ist NICHT repliziert (node-lokale Tabelle, jeder Knoten fuehrt seine eigene Sicht), und in `role` traegt sich jeder Knoten selbst ein. Ein per Join dazugekommener Knoten behaelt dort den Default "primary" — utm-2 behauptete in seiner eigenen Ansicht also, es sei Primary. Der Code weiss das an anderer Stelle bereits: cluster_repair.go dokumentiert "role/pg_role sind je Node lokal und unzuverlaessig", und keepalived.go gibt pg_role deshalb seit v1.2.46 absoluten Vorrang vor role. Die Karte zeigt jetzt pg_role statt role. Das folgt der tatsaechlichen Replikationsrolle und stimmt auf beiden Seiten ueberein (utm-1=primary, utm-2=standby). 'standalone'/leer heisst "keine Replikation eingerichtet" und zeigt bewusst gar keine Rolle, statt eine zu erfinden. Die stale role='primary'-Zeile auf utm-2 wurde separat direkt in der node-lokalen DB korrigiert. Sie war nicht nur kosmetisch: faellt pg_role irgendwann aus (leer/'standalone'), greift in keepalived.go der role-Fallback — und genau diese Konstellation hat 2026-05 schon einmal einen Split-Brain ausgeloest (beide Knoten Prio 200, hoehere IP gewinnt). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -424,8 +424,9 @@
|
|||||||
"split-brain": "split-brain"
|
"split-brain": "split-brain"
|
||||||
},
|
},
|
||||||
"roleDbPrimary": "DB-Primary",
|
"roleDbPrimary": "DB-Primary",
|
||||||
"rolePeer": "Peer",
|
"roleHint": "Datenbank-Rolle aus der Replikation: DB-Primary nimmt Schreibzugriffe entgegen, DB-Standby repliziert von dort. Sie wandert NICHT mit der VIP — ein Knoten kann DB-Primary sein und trotzdem gerade keepalived-BACKUP (siehe VIP/VRRP-Karte). Sie aendert sich nur durch „edgeguard-ctl promote“.",
|
||||||
"roleHint": "DB-/Cluster-Rolle: bestimmt, wohin Schreibzugriffe gehen. Sie wandert NICHT mit der VIP — ein Node kann DB-Primary sein und trotzdem gerade keepalived-BACKUP (siehe VIP/VRRP-Karte). Sie aendert sich nur durch „edgeguard-ctl promote“."
|
"roleDbStandby": "DB-Standby",
|
||||||
|
"roleDbUnknown": "—"
|
||||||
},
|
},
|
||||||
"routingCard": {
|
"routingCard": {
|
||||||
"title": "Routing",
|
"title": "Routing",
|
||||||
|
|||||||
@@ -424,8 +424,9 @@
|
|||||||
"split-brain": "split-brain"
|
"split-brain": "split-brain"
|
||||||
},
|
},
|
||||||
"roleDbPrimary": "DB primary",
|
"roleDbPrimary": "DB primary",
|
||||||
"rolePeer": "Peer",
|
"roleHint": "Database role from replication: the DB primary accepts writes, the DB standby replicates from it. It does NOT follow the VIP — a node can be DB primary while currently being keepalived BACKUP (see the VIP/VRRP card). It only changes via \"edgeguard-ctl promote\".",
|
||||||
"roleHint": "Database/cluster role: decides where writes go. It does NOT follow the VIP — a node can be DB primary while currently being keepalived BACKUP (see the VIP/VRRP card). It only changes via \"edgeguard-ctl promote\"."
|
"roleDbStandby": "DB standby",
|
||||||
|
"roleDbUnknown": "—"
|
||||||
},
|
},
|
||||||
"routingCard": {
|
"routingCard": {
|
||||||
"title": "Routing",
|
"title": "Routing",
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ interface FwRule { id: number; enabled: boolean; action: string }
|
|||||||
interface FwNAT { id: number; enabled: boolean; kind: string }
|
interface FwNAT { id: number; enabled: boolean; kind: string }
|
||||||
interface FwZone { id: number; name: string; builtin: boolean }
|
interface FwZone { id: number; name: string; builtin: boolean }
|
||||||
interface TLSCert { id: number; common_name: string; not_after?: string }
|
interface TLSCert { id: number; common_name: string; not_after?: string }
|
||||||
interface ClusterNode { id: string; fqdn: string; role: string }
|
interface ClusterNode { id: string; fqdn: string; role: string; pg_role?: string }
|
||||||
interface WGIface { id: number; name: string; mode: string; active: boolean }
|
interface WGIface { id: number; name: string; mode: string; active: boolean }
|
||||||
interface WGStatusRow {
|
interface WGStatusRow {
|
||||||
interface: string
|
interface: string
|
||||||
@@ -644,6 +644,24 @@ function VIPCard({ data }: { data?: VIPStatus | null }) {
|
|||||||
|
|
||||||
// ── Cluster card ──────────────────────────────────────────────
|
// ── Cluster card ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// dbRoleLabel/-Color bilden pg_role ab. 'standalone' bzw. leer heisst:
|
||||||
|
// keine Replikation eingerichtet — dann gibt es schlicht keine DB-Rolle
|
||||||
|
// zu zeigen, statt eine zu erfinden.
|
||||||
|
function dbRoleLabel(pgRole: string | undefined, t: (k: string) => string): string {
|
||||||
|
switch (pgRole) {
|
||||||
|
case 'primary': return t('dashboard.clusterCard.roleDbPrimary')
|
||||||
|
case 'standby': return t('dashboard.clusterCard.roleDbStandby')
|
||||||
|
default: return t('dashboard.clusterCard.roleDbUnknown')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function dbRoleColor(pgRole: string | undefined): string {
|
||||||
|
switch (pgRole) {
|
||||||
|
case 'primary': return 'green'
|
||||||
|
case 'standby': return 'blue'
|
||||||
|
default: return 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ClusterStatusCardProps {
|
interface ClusterStatusCardProps {
|
||||||
nodes: ClusterNode[]
|
nodes: ClusterNode[]
|
||||||
status: { mode: string; health: string; drift_found: boolean } | null
|
status: { mode: string; health: string; drift_found: boolean } | null
|
||||||
@@ -677,16 +695,19 @@ function ClusterStatusCard({ nodes, status }: ClusterStatusCardProps) {
|
|||||||
padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12,
|
padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12,
|
||||||
}}>
|
}}>
|
||||||
<code style={{ color: '#334155' }}>{n.fqdn}</code>
|
<code style={{ color: '#334155' }}>{n.fqdn}</code>
|
||||||
{/* ha_nodes.role ist die DB-/Cluster-Rolle (wohin Schreibzugriffe
|
{/* Bewusst pg_role, NICHT role: ha_nodes ist node-lokal (nicht
|
||||||
gehen) und wandert bewusst NICHT mit der VIP — sie aendert
|
repliziert), und jeder Node traegt sich in `role` selbst ein —
|
||||||
sich nur durch `edgeguard-ctl promote`. Ein nacktes "primary"
|
ein per Join dazugekommener Node behaelt dort den Default
|
||||||
hier las sich neben der VIP-Karte ("BACKUP") wie ein
|
"primary" und behauptete deshalb in seiner eigenen Ansicht,
|
||||||
Widerspruch, deshalb explizit als DB-Rolle beschriftet. */}
|
beide Knoten seien DB-Primary. `pg_role` folgt der
|
||||||
|
tatsaechlichen Replikationsrolle und stimmt auf beiden Seiten
|
||||||
|
ueberein. Siehe auch cluster_repair.go: role/pg_role sind
|
||||||
|
node-lokal, verlaesslich ist letztlich die PUBLICATION.
|
||||||
|
Die DB-Rolle wandert NICHT mit der VIP — sie aendert sich nur
|
||||||
|
durch `edgeguard-ctl promote`. */}
|
||||||
<Tooltip title={t('dashboard.clusterCard.roleHint')}>
|
<Tooltip title={t('dashboard.clusterCard.roleHint')}>
|
||||||
<Tag color={n.role === 'primary' ? 'green' : 'default'} style={{ margin: 0 }}>
|
<Tag color={dbRoleColor(n.pg_role)} style={{ margin: 0 }}>
|
||||||
{n.role === 'primary'
|
{dbRoleLabel(n.pg_role, t)}
|
||||||
? t('dashboard.clusterCard.roleDbPrimary')
|
|
||||||
: t('dashboard.clusterCard.rolePeer')}
|
|
||||||
</Tag>
|
</Tag>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user