6 Commits

Author SHA1 Message Date
noroot
aeb4eaa6b6 chore(release): v1.3.37 stable 2026-09-11 12:59:54 +02:00
noroot
4675c6062e feat(alerts): "Quittierte löschen" — Backlog war nur seitenweise raeumbar
Gemeldet als "Loeschen tut nichts". Das Loeschen hat funktioniert: der
Audit-Eintrag zeigt deleted=25 und die IDs waren danach weg. Nur war das
unsichtbar — die Kopf-Checkbox der Tabelle markiert ausschliesslich die
AKTUELLE Seite (25 Zeilen), und bei einem Backlog von ~950 optisch
identischen backend.down-Alarmen ruecken sofort die naechsten 25 nach.
Fuer den Operator sah es aus, als sei nichts passiert; um den Backlog
leerzubekommen waeren ~38 Klick-Runden noetig gewesen.

Neu: POST /alerts/events/delete-acknowledged loescht alle quittierten
Events auf einmal — Gegenstueck zum bereits vorhandenen "Alle
quittieren". Bewusst nur die quittierten: was noch niemand gesehen hat,
soll nicht per Sammelaktion verschwinden. Der Button zeigt die Anzahl
ueber den gesamten Datensatz, nicht nur die sichtbare Seite.

Ausserdem meldet das seitenweise Loeschen jetzt die Anzahl zurueck
("N Alarme geloescht") statt nur "Loeschen" und leert die Auswahl —
damit ist erkennbar, dass etwas passiert ist, auch wenn die Liste
optisch gleich aussieht.

Die automatische Aufraeumung (90 Tage) arbeitet uebrigens korrekt, sie
loescht taeglich die Eintraege die ueber die Grenze rutschen — der
Backlog stammt aus dem Zeitraum davor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:59:15 +02:00
noroot
25bc9c3673 chore(release): v1.3.36 stable 2026-09-11 12:37:09 +02:00
noroot
a54d367c24 fix(cluster): duplicate-key-Warnung bei jedem API-Start auf dem Standby
preRegisterPrimary legte unbedingt eine Platzhalter-Zeile fuer den
Primary an. Sobald der Primary sich aber selbst gemeldet hat, existiert
bereits eine Zeile mit seiner ECHTEN Node-ID und demselben FQDN — und
UpsertSelf nutzt ON CONFLICT (id), greift also nicht. Die Synthetik-ID
"prenode-<fqdn>" lief damit in den fqdn-Unique-Index:

  duplicate key value violates unique constraint "ha_nodes_fqdn_unique"

bei JEDEM API-Start auf dem Standby. Folgenlos — die echte Zeile ist ja
korrekt und der Firewall-Reload lief trotzdem — aber es sah nach einem
Defekt aus und verdeckte echte Warnungen im selben Log.

Jetzt wird zuerst die IP einer vorhandenen Zeile aufgefrischt (mehr will
die Funktion gar nicht) und nur bei RowsAffected()==0 der Platzhalter
angelegt — also genau dann, wenn der Primary sich noch nie gemeldet hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:36:30 +02:00
noroot
6149670375 chore(release): v1.3.35 stable 2026-09-11 12:26:20 +02:00
noroot
1b13df4032 fix(cluster): pg_role fehlte im SELECT — Feld kam nie beim Client an
baseSelect in cluster/store.go selektierte pg_role nicht. HANode.PGRole
war dadurch ueberall ein leerer String, wo Store.List/Get benutzt wird:
/cluster/nodes, /cluster/status und damit auch die pg_role-Spalte der
Cluster-Seite. Aufgefallen ist es erst, als die Dashboard-Cluster-Karte
in v1.3.33 auf pg_role umgestellt wurde und daraufhin bei beiden Knoten
"—" zeigte — der Fehler lag aber schon vorher im Store.

pg_role ist jetzt in baseSelect, im RETURNING von UpsertSelf und in
scanNode ergaenzt; die drei muessen spaltenweise zueinander passen.
UpsertSelf SCHREIBT pg_role weiterhin bewusst nicht (ON CONFLICT laesst
die Spalte unangetastet, damit eine Self-Registrierung den per promote
gesetzten Wert nicht ueberbuegelt) — liest sie aber im RETURNING mit,
sonst stimmt die Scan-Reihenfolge nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:25:40 +02:00
8 changed files with 133 additions and 22 deletions

View File

@@ -1 +1 @@
1.3.34
1.3.37

View File

@@ -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 {

View File

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

View File

@@ -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

View File

@@ -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),

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}