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