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. */}
+