Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb4eaa6b6 | ||
|
|
4675c6062e | ||
|
|
25bc9c3673 | ||
|
|
a54d367c24 | ||
|
|
6149670375 | ||
|
|
1b13df4032 | ||
|
|
3ca37ee226 | ||
|
|
86aee33308 |
@@ -168,7 +168,8 @@ func main() {
|
||||
if st != nil && st.Completed {
|
||||
// Auto-create /etc/edgeguard/node.conf falls fehlt.
|
||||
_, _ = cluster.EnsureLocalConfig("")
|
||||
if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN, "primary", version); err != nil {
|
||||
if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN,
|
||||
localClusterRole(ctx, pool, st), version); err != nil {
|
||||
slog.Warn("self-register in ha_nodes failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -303,12 +304,12 @@ func main() {
|
||||
systemHdl.WithAudit(auditRepo, nodeID)
|
||||
systemHdl.WithDB(pool)
|
||||
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
|
||||
"haproxy": haproxy.New(pool).RenderToString,
|
||||
"nftables": firewallrender.New(pool).RenderToString,
|
||||
"squid": squidrender.New(pool).RenderToString,
|
||||
"unbound": unboundrender.New(pool).RenderToString,
|
||||
"chrony": chronyrender.New(pool).RenderToString,
|
||||
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
|
||||
"haproxy": haproxy.New(pool).RenderToString,
|
||||
"nftables": firewallrender.New(pool).RenderToString,
|
||||
"squid": squidrender.New(pool).RenderToString,
|
||||
"unbound": unboundrender.New(pool).RenderToString,
|
||||
"chrony": chronyrender.New(pool).RenderToString,
|
||||
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
|
||||
"crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
|
||||
})
|
||||
setupHdl.WithAudit(auditRepo, nodeID)
|
||||
@@ -927,3 +928,47 @@ func randomEphemeralSecret() []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// localClusterRole ermittelt die eigene Cluster-Rolle für die node-lokale
|
||||
// ha_nodes-Zeile.
|
||||
//
|
||||
// Befund 2026-09-11: Hier stand fest "primary" — für JEDEN Node, bei jedem
|
||||
// API-Start. ha_nodes ist node-lokal (nicht repliziert), also trug sich auch
|
||||
// ein per Join dazugekommener Standby bei sich selbst als "primary" ein. In
|
||||
// der Cluster-Ansicht DIESES Nodes erschienen dadurch beide Knoten als
|
||||
// Primary, und eine Korrektur direkt in der DB hielt nur bis zum nächsten
|
||||
// Neustart.
|
||||
//
|
||||
// Nicht kosmetisch: keepalived.go nutzt `role` als Fallback, wenn pg_role
|
||||
// nicht 'standby' ist. Ein Standby, der sich selbst "primary" nennt, ist
|
||||
// damit genau der Zustand, der 2026-05 schon einmal einen Split-Brain
|
||||
// ausgelöst hat (beide Knoten Priorität 200, höhere IP gewinnt).
|
||||
//
|
||||
// Verlässlich ist — wie in cluster_repair.go dokumentiert — die
|
||||
// Replikations-Topologie selbst: nur der Primary hat die PUBLICATION, nur
|
||||
// der Standby die SUBSCRIPTION. Beide Kataloge darf der edgeguard-DB-User
|
||||
// lesen. Das ist zugleich selbstheilend: nach `edgeguard-ctl promote` hat
|
||||
// der neue Primary die Publication und meldet sich ab dem nächsten Start
|
||||
// korrekt als "primary" — anders als eine Ableitung aus setup.json, die
|
||||
// den Promote überschreiben würde.
|
||||
func localClusterRole(ctx context.Context, pool *pgxpool.Pool, st *setup.State) string {
|
||||
if pool != nil {
|
||||
var hasPub, hasSub bool
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = 'edgeguard_shared')`,
|
||||
).Scan(&hasPub); err == nil && hasPub {
|
||||
return "primary"
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_subscription WHERE subname = 'edgeguard_sub')`,
|
||||
).Scan(&hasSub); err == nil && hasSub {
|
||||
return "peer"
|
||||
}
|
||||
}
|
||||
// Keine Replikation eingerichtet: ein per Join dazugekommener Node ist
|
||||
// trotzdem kein Primary, alles andere (Founder/Single-Node) schon.
|
||||
if st != nil && st.IsClusterNode {
|
||||
return "peer"
|
||||
}
|
||||
return "primary"
|
||||
}
|
||||
|
||||
@@ -25,8 +25,15 @@ type Store struct {
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
|
||||
|
||||
// baseSelect MUSS spaltenweise zu scanNode passen. pg_role fehlte hier
|
||||
// urspruenglich (Befund 2026-09-11): HANode.PGRole kam dadurch ueberall als
|
||||
// leerer String an, wo Store.List/Get benutzt wird — /cluster/nodes,
|
||||
// /cluster/status und damit auch die pg_role-Spalte der Cluster-Seite.
|
||||
// UpsertSelf schreibt pg_role bewusst NICHT (ON CONFLICT laesst die Spalte
|
||||
// unangetastet), liest sie aber im RETURNING mit — sonst passt die
|
||||
// Scan-Reihenfolge nicht.
|
||||
const baseSelect = `
|
||||
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role,
|
||||
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role, pg_role,
|
||||
version, config_hash, status,
|
||||
last_seen, joined_at, created_at, updated_at
|
||||
FROM ha_nodes
|
||||
@@ -90,7 +97,7 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
last_seen = EXCLUDED.last_seen,
|
||||
updated_at = NOW()
|
||||
RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
|
||||
role, version, config_hash, status,
|
||||
role, pg_role, version, config_hash, status,
|
||||
last_seen, joined_at, created_at, updated_at`,
|
||||
n.ID, n.Name, n.FQDN, n.APIURL,
|
||||
n.PublicIP, n.InternalIP, n.MgmtIP,
|
||||
@@ -181,7 +188,7 @@ func scanNode(row interface{ Scan(...any) error }) (*models.HANode, error) {
|
||||
if err := row.Scan(
|
||||
&n.ID, &n.Name, &n.FQDN, &n.APIURL,
|
||||
&n.PublicIP, &n.InternalIP, &n.MgmtIP,
|
||||
&n.Role, &n.Version, &n.ConfigHash, &n.Status,
|
||||
&n.Role, &n.PGRole, &n.Version, &n.ConfigHash, &n.Status,
|
||||
&n.LastSeen, &n.JoinedAt,
|
||||
&n.CreatedAt, &n.UpdatedAt,
|
||||
); err != nil {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
// POST /api/v1/alerts/events/acknowledge — Bulk-Quittieren {ids:[…]}
|
||||
// POST /api/v1/alerts/events/acknowledge-all — alle offenen quittieren
|
||||
// POST /api/v1/alerts/events/delete — Bulk-Löschen {ids:[…]}
|
||||
// POST /api/v1/alerts/events/delete-acknowledged — alle quittierten löschen
|
||||
type AlertsHandler struct {
|
||||
Service *alerts.Service
|
||||
Audit *audit.Repo
|
||||
@@ -43,6 +44,7 @@ func (h *AlertsHandler) Register(rg *gin.RouterGroup) {
|
||||
g.POST("/events/acknowledge", h.AcknowledgeEvents)
|
||||
g.POST("/events/acknowledge-all", h.AcknowledgeAllEvents)
|
||||
g.POST("/events/delete", h.DeleteEvents)
|
||||
g.POST("/events/delete-acknowledged", h.DeleteAcknowledgedEvents)
|
||||
}
|
||||
|
||||
func (h *AlertsHandler) ListChannels(c *gin.Context) {
|
||||
@@ -192,3 +194,18 @@ func (h *AlertsHandler) DeleteEvents(c *gin.Context) {
|
||||
strconv.Itoa(len(req.IDs)), gin.H{"ids": req.IDs, "deleted": n}, h.NodeID)
|
||||
response.OK(c, gin.H{"deleted": n})
|
||||
}
|
||||
|
||||
// DeleteAcknowledgedEvents löscht alle quittierten Events auf einmal.
|
||||
// Gegenstück zu AcknowledgeAllEvents — ohne das kam man an einen
|
||||
// groesseren Backlog nur seitenweise heran (die Kopf-Checkbox der
|
||||
// Tabelle markiert nur die aktuelle Seite).
|
||||
func (h *AlertsHandler) DeleteAcknowledgedEvents(c *gin.Context) {
|
||||
n, err := h.Service.DeleteAcknowledged(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.delete_acknowledged",
|
||||
"acknowledged", gin.H{"deleted": n}, h.NodeID)
|
||||
response.OK(c, gin.H{"deleted": n})
|
||||
}
|
||||
|
||||
@@ -253,22 +253,46 @@ func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) {
|
||||
}
|
||||
ip := addrs[0]
|
||||
|
||||
// Stable ID so repeated calls (join + startup) don't accumulate rows.
|
||||
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
|
||||
n := models.HANode{
|
||||
ID: nodeID,
|
||||
Name: primaryFQDN,
|
||||
FQDN: primaryFQDN,
|
||||
APIURL: "https://" + primaryFQDN + ":3443",
|
||||
Role: "primary",
|
||||
Status: "online",
|
||||
}
|
||||
n.PublicIP = &ip
|
||||
|
||||
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil {
|
||||
slog.Warn("setup: pre-register primary in ha_nodes failed", "fqdn", primaryFQDN, "error", err)
|
||||
// Sobald der Primary sich selbst gemeldet hat (Heartbeat-Push), steht
|
||||
// hier bereits eine Zeile mit seiner ECHTEN Node-ID. Dann ist nur die
|
||||
// IP frisch zu halten — mehr will diese Funktion gar nicht.
|
||||
//
|
||||
// Vorher lief in dem Fall unbedingt der Platzhalter-Upsert unten, der
|
||||
// am fqdn-Unique-Index scheiterte: UpsertSelf nutzt ON CONFLICT (id),
|
||||
// und die Synthetik-ID "prenode-<fqdn>" trifft die echte Zeile nicht.
|
||||
// Ergebnis war ein "duplicate key value violates unique constraint
|
||||
// ha_nodes_fqdn_unique" bei JEDEM API-Start auf dem Standby — folgenlos
|
||||
// (die echte Zeile stimmt ja), aber es sah nach einem Defekt aus und
|
||||
// verdeckte echte Warnungen.
|
||||
tag, err := h.ClusterStore.Pool.Exec(ctx,
|
||||
`UPDATE ha_nodes SET public_ip = $1, updated_at = NOW() WHERE fqdn = $2`,
|
||||
ip, primaryFQDN)
|
||||
if err != nil {
|
||||
slog.Warn("setup: refreshing primary IP in ha_nodes failed",
|
||||
"fqdn", primaryFQDN, "error", err)
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
// Noch keine Zeile: Platzhalter anlegen, damit @peer_ipv4 den
|
||||
// Primary schon kennt, bevor er sich das erste Mal meldet.
|
||||
// Stabile ID, damit wiederholte Aufrufe (Join + Start) keine
|
||||
// Zeilen anhaeufen.
|
||||
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
|
||||
n := models.HANode{
|
||||
ID: nodeID,
|
||||
Name: primaryFQDN,
|
||||
FQDN: primaryFQDN,
|
||||
APIURL: "https://" + primaryFQDN + ":3443",
|
||||
Role: "primary",
|
||||
Status: "online",
|
||||
}
|
||||
n.PublicIP = &ip
|
||||
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil {
|
||||
slog.Warn("setup: pre-register primary in ha_nodes failed",
|
||||
"fqdn", primaryFQDN, "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.PeerReloader(ctx); err != nil {
|
||||
slog.Warn("setup: PeerReloader failed after primary pre-register", "error", err)
|
||||
return
|
||||
|
||||
@@ -240,6 +240,26 @@ func (s *Service) DeleteEvents(ctx context.Context, ids []int64) (int64, error)
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// DeleteAcknowledged löscht alle bereits quittierten Events und liefert
|
||||
// die Anzahl. Backing für "Alle quittierten löschen".
|
||||
//
|
||||
// Warum das gebraucht wird (Befund 2026-09-11): Bulk-Löschen ging nur über
|
||||
// die Tabellen-Auswahl, und deren Kopf-Checkbox markiert nur die AKTUELLE
|
||||
// Seite. Bei einem Backlog von ~950 Alt-Alarmen loescht ein Klick also 25
|
||||
// Zeilen, woraufhin die naechsten 25 — optisch identischen — nachruecken.
|
||||
// Es sah aus, als passiere nichts, obwohl korrekt geloescht wurde.
|
||||
//
|
||||
// Bewusst nur die quittierten: was noch niemand gesehen hat, soll nicht
|
||||
// per Sammelaktion verschwinden.
|
||||
func (s *Service) DeleteAcknowledged(ctx context.Context) (int64, error) {
|
||||
tag, err := s.Pool.Exec(ctx,
|
||||
`DELETE FROM alert_events WHERE acknowledged_at IS NOT NULL`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// Cleanup löscht alert_events älter als keepDays und liefert die Anzahl
|
||||
// gelöschter Rows. make_interval(days => $1) nimmt $1 sauber als int —
|
||||
// der frühere ($1 || ' days')::interval-Ansatz erzwang text und scheiterte
|
||||
@@ -316,7 +336,7 @@ func (s *Service) sendWebhook(ctx context.Context, c Channel, kind string,
|
||||
"severity": string(sev),
|
||||
"subject": subject,
|
||||
"message": message,
|
||||
"content": fmt.Sprintf("[%s] %s: %s\n%s",
|
||||
"content": fmt.Sprintf("[%s] %s: %s\n%s",
|
||||
strings.ToUpper(string(sev)), kind, subject, message),
|
||||
"text": fmt.Sprintf("*[%s]* %s — %s\n%s",
|
||||
strings.ToUpper(string(sev)), kind, subject, message),
|
||||
|
||||
@@ -1558,7 +1558,10 @@
|
||||
"subject": "Betreff",
|
||||
"delivered": "Gesendet",
|
||||
"status": "Status"
|
||||
}
|
||||
},
|
||||
"deleteAcknowledged": "Quittierte löschen",
|
||||
"confirmDeleteAcked": "{{n}} quittierte Alarme endgültig löschen? Offene Alarme bleiben erhalten.",
|
||||
"eventsDeleted": "{{n}} Alarme gelöscht."
|
||||
},
|
||||
"remotes": {
|
||||
"scopeTitle": "Off-Site-Backup-Ziele",
|
||||
|
||||
@@ -1558,7 +1558,10 @@
|
||||
"subject": "Subject",
|
||||
"delivered": "Delivered",
|
||||
"status": "Status"
|
||||
}
|
||||
},
|
||||
"deleteAcknowledged": "Delete acknowledged",
|
||||
"confirmDeleteAcked": "Permanently delete {{n}} acknowledged alerts? Open alerts are kept.",
|
||||
"eventsDeleted": "{{n}} alerts deleted."
|
||||
},
|
||||
"remotes": {
|
||||
"scopeTitle": "Off-site backup targets",
|
||||
|
||||
@@ -120,9 +120,34 @@ export default function AlertsPage() {
|
||||
})
|
||||
const delEventsMut = useMutation({
|
||||
mutationFn: (ids: number[]) => apiClient.post('/alerts/events/delete', { ids }),
|
||||
onSuccess: () => { message.success(t('common.delete')); refreshEvents() },
|
||||
onSuccess: (_d, ids) => {
|
||||
// Anzahl mitsagen: die Kopf-Checkbox markiert nur die AKTUELLE Seite,
|
||||
// und bei einem groesseren Backlog ruecken sofort optisch identische
|
||||
// Eintraege nach. Ohne diese Rueckmeldung sieht es aus, als sei nichts
|
||||
// passiert (Befund 2026-09-11).
|
||||
message.success(t('alerts.eventsDeleted', { n: ids.length }))
|
||||
setSelectedIds([])
|
||||
refreshEvents()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const delAckedMut = useMutation({
|
||||
mutationFn: () => apiClient.post('/alerts/events/delete-acknowledged'),
|
||||
onSuccess: (r) => {
|
||||
const n = (r?.data as { data?: { deleted?: number } })?.data?.deleted ?? 0
|
||||
message.success(t('alerts.eventsDeleted', { n }))
|
||||
setSelectedIds([])
|
||||
refreshEvents()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
// Anzahl quittierter Events ueber den GESAMTEN Datensatz, nicht nur die
|
||||
// sichtbare Seite — der Button soll zeigen, was er wirklich raeumt.
|
||||
const ackedCount = useMemo(
|
||||
() => (events.data ?? []).filter(e => e.acknowledged_at).length,
|
||||
[events.data],
|
||||
)
|
||||
|
||||
const kindOptions = useMemo(() => {
|
||||
const kinds = [...new Set((events.data ?? []).map(e => e.kind))].sort()
|
||||
@@ -429,6 +454,18 @@ export default function AlertsPage() {
|
||||
{t('alerts.acknowledgeAll')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/* Gegenstueck zu "Alle quittieren": ohne das kommt man an
|
||||
einen groesseren Backlog nur seitenweise heran, weil die
|
||||
Kopf-Checkbox der Tabelle nur die aktuelle Seite markiert. */}
|
||||
<Popconfirm title={t('alerts.confirmDeleteAcked', { n: ackedCount })}
|
||||
onConfirm={() => delAckedMut.mutate()}
|
||||
disabled={isViewer || ackedCount === 0}>
|
||||
<Button size="small" danger ghost
|
||||
disabled={isViewer || ackedCount === 0}
|
||||
loading={delAckedMut.isPending}>
|
||||
{t('alerts.deleteAcknowledged')}{ackedCount > 0 ? ` (${ackedCount})` : ''}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
<Table size="small" rowKey="id" loading={events.isFetching}
|
||||
|
||||
Reference in New Issue
Block a user