From 4675c6062e6b8e73a652cfa69f64e74eda4025ee Mon Sep 17 00:00:00 2001 From: noroot Date: Fri, 11 Sep 2026 12:59:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(alerts):=20"Quittierte=20l=C3=B6schen"=20?= =?UTF-8?q?=E2=80=94=20Backlog=20war=20nur=20seitenweise=20raeumbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/handlers/alerts.go | 17 ++++++++ internal/services/alerts/alerts.go | 22 ++++++++++- management-ui/src/i18n/locales/de/common.json | 5 ++- management-ui/src/i18n/locales/en/common.json | 5 ++- management-ui/src/pages/Alerts/index.tsx | 39 ++++++++++++++++++- 5 files changed, 84 insertions(+), 4 deletions(-) diff --git a/internal/handlers/alerts.go b/internal/handlers/alerts.go index ebf16d3..c70a2e4 100644 --- a/internal/handlers/alerts.go +++ b/internal/handlers/alerts.go @@ -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}) +} diff --git a/internal/services/alerts/alerts.go b/internal/services/alerts/alerts.go index 4908e56..ae1d35d 100644 --- a/internal/services/alerts/alerts.go +++ b/internal/services/alerts/alerts.go @@ -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), diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index ceec102..91c8844 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -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", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 382d6d4..4570c0a 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -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", diff --git a/management-ui/src/pages/Alerts/index.tsx b/management-ui/src/pages/Alerts/index.tsx index bf57949..14de8c5 100644 --- a/management-ui/src/pages/Alerts/index.tsx +++ b/management-ui/src/pages/Alerts/index.tsx @@ -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')} + {/* 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. */} + delAckedMut.mutate()} + disabled={isViewer || ackedCount === 0}> + +