Skip to content

Commit d30c682

Browse files
authored
feat(alerts): resolved events stay in the log, referenced by their closing event (#1349) (#1396)
Resolve used to DELETE the original event from alert_log: after an outdated -> updated cycle only the 'resolved' event remained, with no way to review what had actually happened. - alert_log gains resolved_ts (defensive migration). Resolve marks the event (and unsets its read mirror so a recurrence with the same stable id cannot inherit the old read state - #1107 still holds) instead of deleting the row. - loadPersisted brings resolved events back into the feed MARKED (resolvedTs, always read, and they never seed the dedup map: a recurrence right after a restart must alert immediately, same as the hot Resolve path). - persistLocked upserts resolved_ts, so a recurrence with the same stable id un-resolves the row. - AlertEvent gains resolves[]: the closing event references what it closed. Filled for agent outdated->updated and snmp failing->recovered (the two Resolve pairs; volatile incidents use Remove and never reach the log). - UI: resolved events carry a Resolved badge, count under the Resolved filter tab (warnings/info exclude them, All keeps them), and expanding a closing event lists the events it resolved (dropdown). i18n es/en. Tests updated to the new contract (resolved events persist across restart, read-forgetting on recurrence) plus the pre-existing suite. Closes #1349
1 parent 96ec5e5 commit d30c682

9 files changed

Lines changed: 133 additions & 15 deletions

File tree

‎app/public/locales/en/translation.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
},
5050
"filterByCategory": "Filter by category",
5151
"urgentBadge": "URGENT",
52+
"resolvedBadge": "Resolved",
53+
"resolvesTitle_one": "Resolves {{count}} event",
54+
"resolvesTitle_other": "Resolves {{count}} events",
5255
"configButton": "Configure alerts",
5356
"configTitle": "Alert settings",
5457
"configDescription": "Choose which events are kept in your history and counted on the bell, per category.",

‎app/public/locales/es/translation.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
},
5050
"filterByCategory": "Filtrar por categoría",
5151
"urgentBadge": "URGENTE",
52+
"resolvedBadge": "Resuelta",
53+
"resolvesTitle_one": "Resuelve {{count}} evento",
54+
"resolvesTitle_other": "Resuelve {{count}} eventos",
5255
"configButton": "Configurar alertas",
5356
"configTitle": "Configuración de alertas",
5457
"configDescription": "Elige qué eventos entran en tu historial y cuentan en la campana, por categoría.",

‎app/src/data/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,12 @@ export interface AlertEvent {
369369
routerId?: string
370370
/** Router padre que suprime esta alerta (issue #332). Ausente si no aplica. */
371371
suppressedBy?: string
372+
/** #1349: unix segundos de cuándo se resolvió. Ausente/0 = activa. El
373+
* evento resuelto se conserva en el log (pestaña Resueltas). */
374+
resolvedTs?: number
375+
/** #1349: IDs de los eventos que este evento de cierre resuelve. La UI
376+
* los lista al expandirlo. */
377+
resolves?: string[]
372378
}
373379

374380
// ---------------------------------------------------------------------------

‎app/src/pages/Alerts.tsx‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,11 +300,14 @@ interface FeedRowProps {
300300
onSilence?: (id: string, duration: '1h' | '24h' | 'forever') => void
301301
onDismiss?: (id: string) => void
302302
action?: { icon: LucideIcon; title: string; onClick: () => void }
303+
/** #1349: feed por id para resolver los eventos que un evento de cierre
304+
* (`ev.resolves`) cerró y listarlos al expandirlo. */
305+
feedById?: Map<string, FeedEvent>
303306
}
304307

305308
// #862: RowAction vive en components/RowAction.tsx (compartido con AlertItem).
306309

307-
function FeedRow({ ev, index, read, expanded, onToggle, reduce, onSilence, onDismiss, action }: FeedRowProps) {
310+
function FeedRow({ ev, index, read, expanded, onToggle, reduce, onSilence, onDismiss, action, feedById }: FeedRowProps) {
308311
const { t } = useTranslation()
309312
const sev = SEVERITY[ev.severity]
310313
const Icon = ev.icon ?? sev.icon
@@ -370,6 +373,12 @@ function FeedRow({ ev, index, read, expanded, onToggle, reduce, onSilence, onDis
370373
{t('alerts.urgentBadge')}
371374
</span>
372375
)}
376+
{/* #1349: marca visible del episodio cerrado en el log */}
377+
{!!ev.resolvedTs && ev.resolvedTs > 0 && (
378+
<span className="shrink-0 rounded-md bg-ok/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-[0.05em] text-ok">
379+
{t('alerts.resolvedBadge')}
380+
</span>
381+
)}
373382
{catMeta && CatIcon && (
374383
<span
375384
className={cn(
@@ -482,6 +491,30 @@ function FeedRow({ ev, index, read, expanded, onToggle, reduce, onSilence, onDis
482491
>
483492
<div className="mx-3 mb-2 mt-1">
484493
<ContextPanel ev={ev} animateIn={!reduce} />
494+
{/* #1349: el evento de cierre lista los eventos que resolvió
495+
(conservados en el log marcados, no borrados). */}
496+
{expanded && ev.resolves && ev.resolves.length > 0 && feedById && (
497+
<div className="mt-2 rounded-lg border border-border bg-canvas px-3 py-2">
498+
<p className="text-[11px] font-semibold uppercase tracking-[0.05em] text-text-muted">
499+
{t('alerts.resolvesTitle', { count: ev.resolves.length })}
500+
</p>
501+
<ul className="mt-1 space-y-1">
502+
{ev.resolves.map((rid) => {
503+
const orig = feedById.get(rid)
504+
return (
505+
<li key={rid} className="flex items-center justify-between gap-2 text-caption">
506+
<span className="min-w-0 truncate text-text-secondary">
507+
{orig ? alertTitle(t, orig) : rid}
508+
</span>
509+
{orig && (
510+
<span className="shrink-0 font-mono text-[10px] text-text-muted">{alertRelTime(orig)}</span>
511+
)}
512+
</li>
513+
)
514+
})}
515+
</ul>
516+
</div>
517+
)}
485518
</div>
486519
</motion.div>
487520
)}
@@ -613,7 +646,15 @@ export default function Alerts() {
613646
const filtered = useMemo(
614647
() =>
615648
alertFeed.filter((ev) => {
616-
if (sev !== 'todas' && ev.severity !== SEV_MATCH[sev]) return false
649+
// #1349: un evento resuelto (resolvedTs) pertenece a la pestaña
650+
// Resueltas; en Avisos/Info ya no cuenta (su condición cerró).
651+
// "Todas" lo muestra: es el registro de actividad completo.
652+
if (sev === 'resueltas') {
653+
if (ev.severity !== 'ok' && !(ev.resolvedTs && ev.resolvedTs > 0)) return false
654+
} else if (sev !== 'todas') {
655+
if (ev.resolvedTs && ev.resolvedTs > 0) return false
656+
if (ev.severity !== SEV_MATCH[sev]) return false
657+
}
617658
if (cats.size > 0 && !cats.has(ev.category)) return false
618659
if (onlyUnread && isRead(ev)) return false
619660
return true
@@ -629,6 +670,9 @@ export default function Alerts() {
629670
[filtered],
630671
)
631672

673+
// #1349: lookup por id para el dropdown de "eventos que resuelve".
674+
const feedById = useMemo(() => new Map(alertFeed.map((ev) => [ev.id, ev])), [alertFeed])
675+
632676
// Infinite scroll simulado: skeleton → fin del historial (alerts.md §Scroll)
633677
useEffect(() => {
634678
const el = sentinelRef.current
@@ -995,6 +1039,7 @@ export default function Alerts() {
9951039
onSilence={silenceAlert}
9961040
onDismiss={dismissAlert}
9971041
action={actionOf(ev)}
1042+
feedById={feedById}
9981043
/>
9991044
))}
10001045
</ul>

‎server-go/internal/adapters/agent.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -991,7 +991,10 @@ func (l *Live) checkAgentVersion(cfg RouterConfig, p *probe.Payload) {
991991
Description: fmt.Sprintf("%s now pushes version %s of %s", name, p.Version, kindLabel),
992992
Type: alerts.TypeAgentUpdated,
993993
Vars: map[string]string{"router": name, "version": p.Version, "kind": kindLabel},
994-
Time: "just now", RouterID: cfg.ID,
994+
// #1349: el evento de cierre referencia al que resuelve
995+
// (conservado en el log marcado, no borrado).
996+
Resolves: []string{fmt.Sprintf("alert-agent-outdated-%s", cfg.ID)},
997+
Time: "just now", RouterID: cfg.ID,
995998
})
996999
}
9971000
}

‎server-go/internal/adapters/snmp_live.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,8 @@ func (l *Live) recordSnmpSuccess(cfg RouterConfig) {
256256

257257
if wasOpen {
258258
log.Printf("[netpulse] SNMP recuperado %s", cfg.LogLabel())
259-
l.engine.Resolve(fmt.Sprintf("alert-snmp-failing-%s", cfg.ID))
259+
failID := fmt.Sprintf("alert-snmp-failing-%s", cfg.ID)
260+
l.engine.Resolve(failID)
260261
l.engine.Emit(AlertEvent{
261262
ID: fmt.Sprintf("alert-snmp-recovered-%s-%d", cfg.ID, now),
262263
Category: alerts.CatSystem, Urgent: false,
@@ -265,6 +266,7 @@ func (l *Live) recordSnmpSuccess(cfg RouterConfig) {
265266
Description: fmt.Sprintf("SNMP polling for %s is responding again", name),
266267
Type: alerts.TypeSnmpRecovered,
267268
Vars: map[string]string{"router": name},
269+
Resolves: []string{failID}, // #1349
268270
Time: "just now", RouterID: cfg.ID,
269271
})
270272
}

‎server-go/internal/alerts/alerts.go‎

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ type AlertEvent struct {
7979
// SuppressedBy is the routerId of the parent router whose offline alert
8080
// is suppressing this one (issue #332). Empty when not suppressed.
8181
SuppressedBy string `json:"suppressedBy,omitempty"`
82+
// ResolvedTs (#1349): unix SEGUNDOS de cuándo se resolvió la condición.
83+
// 0/ausente = activa. El evento resuelto NO se borra del log: queda
84+
// marcado para revisión posterior (pestaña Resueltas) y una
85+
// reincidencia con el mismo ID estable lo des-resuelve (upsert).
86+
ResolvedTs int64 `json:"resolvedTs,omitempty"`
87+
// Resolves (#1349): IDs de los eventos que este evento CIERRA (p. ej.
88+
// "Agent updated" resuelve "alert-agent-outdated-<id>"). La UI los
89+
// muestra al expandir el evento de cierre.
90+
Resolves []string `json:"resolves,omitempty"`
8291
// Volatile (#966): la alerta vive SOLO en memoria (no se persiste en
8392
// alert_log ni sobrevive a reinicios) y desaparece del feed cuando su
8493
// condicion se resuelve (engine.Remove). Uso: caida de agente, cuya
@@ -319,7 +328,7 @@ func (e *Engine) loadPersistedLocked() {
319328
}
320329
rows, err := e.db.Query(
321330
`SELECT id, ts, category, type, severity, urgent, title, description, hint,
322-
router_id, suppressed_by, vars, read_flag
331+
router_id, suppressed_by, vars, read_flag, resolved_ts
323332
FROM alert_log ORDER BY ts DESC LIMIT ?`, MaxEvents)
324333
if err != nil {
325334
return
@@ -335,13 +344,18 @@ func (e *Engine) loadPersistedLocked() {
335344
var typ, sev, hint, routerID, suppressedBy, varsRaw string
336345
var urgent, read int
337346
if err := rows.Scan(&ev.ID, &ev.Ts, &ev.Category, &typ, &sev, &urgent,
338-
&ev.Title, &ev.Description, &hint, &routerID, &suppressedBy, &varsRaw, &read); err != nil {
347+
&ev.Title, &ev.Description, &hint, &routerID, &suppressedBy, &varsRaw, &read, &ev.ResolvedTs); err != nil {
339348
return
340349
}
341350
// #833: una alerta limpiada no vuelve a aparecer tras un reinicio.
342351
if e.dismissed[ev.ID] {
343352
continue
344353
}
354+
// #1349: un evento resuelto vuelve al feed MARCADO (historia
355+
// consultable en Resueltas) y nunca como pendiente de leer.
356+
if ev.ResolvedTs > 0 {
357+
read = 1
358+
}
345359
ev.Type = typ
346360
ev.Severity = sev
347361
ev.Urgent = urgent != 0
@@ -363,8 +377,13 @@ func (e *Engine) loadPersistedLocked() {
363377
key := r.ev.Category + "|" + r.ev.Title + "|" + r.ev.RouterID
364378
// Las filas vienen de más reciente a más antigua: solo la primera por
365379
// clave es el timestamp de dedup válido (no pisar con la más vieja).
366-
if _, ok := e.dedup[key]; !ok {
367-
e.dedup[key] = r.ev.Ts * 1000
380+
// #1349: un RESUELTO no siembra dedup - su condición está cerrada y
381+
// una reincidencia tras reinicio debe alertar de inmediato (mismo
382+
// criterio que Resolve en caliente, que borra la clave de dedup).
383+
if r.ev.ResolvedTs == 0 {
384+
if _, ok := e.dedup[key]; !ok {
385+
e.dedup[key] = r.ev.Ts * 1000
386+
}
368387
}
369388
if r.read && r.ev.ID != "" && !e.readSet[r.ev.ID] {
370389
e.readSet[r.ev.ID] = true
@@ -508,14 +527,15 @@ func (e *Engine) persistLocked(ev AlertEvent) {
508527
urgent = 1
509528
}
510529
_, _ = e.db.Exec(
511-
`INSERT INTO alert_log (id, ts, category, type, severity, urgent, title, description, hint, router_id, suppressed_by, vars)
512-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
530+
`INSERT INTO alert_log (id, ts, category, type, severity, urgent, title, description, hint, router_id, suppressed_by, vars, resolved_ts)
531+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
513532
ON CONFLICT(id) DO UPDATE SET ts=excluded.ts, category=excluded.category,
514533
type=excluded.type, severity=excluded.severity, urgent=excluded.urgent,
515534
title=excluded.title, description=excluded.description, hint=excluded.hint,
516-
router_id=excluded.router_id, suppressed_by=excluded.suppressed_by, vars=excluded.vars`,
535+
router_id=excluded.router_id, suppressed_by=excluded.suppressed_by, vars=excluded.vars,
536+
resolved_ts=excluded.resolved_ts`,
517537
ev.ID, ev.Ts, ev.Category, ev.Type, ev.Severity, urgent,
518-
ev.Title, ev.Description, ev.Hint, ev.RouterID, ev.SuppressedBy, varsRaw)
538+
ev.Title, ev.Description, ev.Hint, ev.RouterID, ev.SuppressedBy, varsRaw, ev.ResolvedTs)
519539
_, _ = e.db.Exec(
520540
`DELETE FROM alert_log WHERE id NOT IN
521541
(SELECT id FROM alert_log ORDER BY ts DESC LIMIT ?)`, PersistMaxEvents)
@@ -986,7 +1006,14 @@ func (e *Engine) Resolve(id string) {
9861006
}
9871007
e.saveReadLocked()
9881008
if e.db != nil {
989-
_, _ = e.db.Exec("DELETE FROM alert_log WHERE id = ?", id)
1009+
// #1349: el evento resuelto se CONSERVA en el log (marcado con su ts
1010+
// de cierre) en vez de borrarse: el registro de actividad mantiene
1011+
// qué pasó y cuándo se cerró. La pestaña Resueltas de la UI lo lista.
1012+
// read_flag=0: el resuelto se muestra leído por la regla de carga
1013+
// (resolved_ts>0 → leído), y si la condición REINCIDE con el mismo
1014+
// ID estable, el upsert (resolved_ts=0) no puede heredar un leído
1015+
// viejo de la fila conservada (#1107 sigue valiendo).
1016+
_, _ = e.db.Exec("UPDATE alert_log SET resolved_ts = ?, read_flag = 0 WHERE id = ?", time.Now().Unix(), id)
9901017
}
9911018
}
9921019

‎server-go/internal/alerts/dismiss_test.go‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,35 @@ func TestResolvePersistsAcrossRestart(t *testing.T) {
130130
e := New(d, nil)
131131
e.Emit(ev("a1", CatSystem, false))
132132
e.Resolve("a1")
133+
// En memoria sale del feed activo en caliente (como siempre).
134+
if e.Has("a1") {
135+
t.Fatal("Has tras resolve en caliente")
136+
}
133137

138+
// #1349: tras reinicio el evento resuelto VUELVE al log marcado
139+
// (resolvedTs>0, leído): el registro conserva qué pasó y cuándo cerró.
134140
e2 := New(d, nil)
135-
if got := len(e2.List()); got != 0 {
136-
t.Fatalf("lista tras reinicio: %d, esperaba 0", got)
141+
got := e2.List()
142+
if len(got) != 1 {
143+
t.Fatalf("lista tras reinicio: %d, esperaba 1 (resuelto conservado)", len(got))
144+
}
145+
if got[0].ResolvedTs == 0 {
146+
t.Fatal("el evento cargado debe llevar resolvedTs")
147+
}
148+
if !got[0].Read {
149+
t.Fatal("el resuelto nunca vuelve como pendiente de leer")
150+
}
151+
152+
// Reincidencia con el mismo ID estable: el upsert lo des-resuelve y
153+
// vuelve activo y no leído (#1107 sigue valiendo con el log conservado).
154+
e2.Emit(ev("a1", CatSystem, false))
155+
e3 := New(d, nil)
156+
got3 := e3.List()
157+
if len(got3) != 1 || got3[0].ResolvedTs != 0 {
158+
t.Fatalf("reincidencia debe des-resolver: %+v", got3)
159+
}
160+
if got3[0].Read {
161+
t.Fatal("la reincidencia no puede heredar el leído")
137162
}
138163
}
139164

‎server-go/internal/db/db.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,10 @@ func Open(dataDir string, opts ...OpenOption) (*DB, error) {
581581
migrate(sqldb, "routers", "mac", "ALTER TABLE routers ADD COLUMN mac TEXT")
582582
// Ancho de canal de los vecinos (#1087), con el agente nuevo; 0 = viejo.
583583
migrate(sqldb, "wifi_scans", "width_mhz", "ALTER TABLE wifi_scans ADD COLUMN width_mhz INTEGER NOT NULL DEFAULT 0")
584+
// #1349: el evento resuelto NO se borra del log: queda marcado con su
585+
// ts de resolución para revisión posterior (y el evento de cierre lo
586+
// referencia con `resolves`). 0 = activo/no resuelto.
587+
migrate(sqldb, "alert_log", "resolved_ts", "ALTER TABLE alert_log ADD COLUMN resolved_ts INTEGER NOT NULL DEFAULT 0")
584588
// issue #241: target de firmware por router (string libre; NULL/"" = sin comprobar).
585589
migrate(sqldb, "routers", "firmware_target", "ALTER TABLE routers ADD COLUMN firmware_target TEXT")
586590
// issue #309: SNMP polling for managed switches.

0 commit comments

Comments
 (0)