Skip to content

Commit 2ea6c8b

Browse files
authored
feat: opt-in alert when a marked device goes offline (#1354)
Closes #1354. Opt-in per-device offline alerting: notify_offline override + editor switch, volatile self-resolving alert with 3-cycle grace and warmup guard. Router-offline slug split with idempotent log migration.
1 parent c3728f9 commit 2ea6c8b

15 files changed

Lines changed: 375 additions & 70 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"agent-outdated": "Update the agent from Settings, Agents (Update button); if it's a NetGrip panel, update it from its own panel.",
8282
"degraded-link": "Check the cable and connectors: a degraded link is often a sign of a damaged cable or a loose connector.",
8383
"device-offline": "Verify the unit's power and network cable.",
84+
"router-offline": "Verify the unit's power and network cable.",
8485
"firmware-outdated": "Your router has newer firmware available; check Firmware upgrades (Labs) or LuCI.",
8586
"gateway-unreachable": "Check the connection between the gateway and the rest of the network.",
8687
"ghost-port": "Check the cable and the connected device: an active port that goes silent usually means a loose cable or a powered-off device.",
@@ -131,6 +132,10 @@
131132
"description": "Negotiated speed dropped from {{prev}} to {{speed}} Mbps for {{polls}} consecutive polls"
132133
},
133134
"device-offline": {
135+
"title": "Device offline: {{device}}",
136+
"description": "{{device}} went offline on {{router}}"
137+
},
138+
"router-offline": {
134139
"title": "{{router}} offline",
135140
"description": "No response from {{host}}: {{error}}"
136141
},
@@ -429,6 +434,7 @@
429434
"nameHint": "Empty = automatic name (DHCP hostname)",
430435
"type": "Type",
431436
"icon": "Icon",
437+
"notifyOffline": "Notify when offline",
432438
"auto": "Auto",
433439
"save": "Save",
434440
"saved": "Device updated",

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"agent-outdated": "Actualiza el agente desde Ajustes, Agentes (botón Actualizar); si es un panel NetGrip, actualiza desde su propio panel.",
8282
"degraded-link": "Revisa el cable y los conectores: un enlace degradado suele ser síntoma de cable dañado o conector flojo.",
8383
"device-offline": "Verifica la alimentación y el cable de red del equipo.",
84+
"router-offline": "Verifica la alimentación y el cable de red del equipo.",
8485
"firmware-outdated": "Tu router tiene firmware más reciente disponible; revisa Actualizaciones de firmware (Laboratorio) o LuCI.",
8586
"gateway-unreachable": "Comprueba la conexión entre la puerta de enlace y el resto de la red.",
8687
"ghost-port": "Revisa el cable y el dispositivo conectado: un puerto activo que enmudece suele indicar un cable suelto o un equipo apagado.",
@@ -131,6 +132,10 @@
131132
"description": "La velocidad negociada cayó de {{prev}} a {{speed}} Mbps durante {{polls}} sondeos consecutivos"
132133
},
133134
"device-offline": {
135+
"title": "Dispositivo desconectado: {{device}}",
136+
"description": "{{device}} se ha desconectado de {{router}}"
137+
},
138+
"router-offline": {
134139
"title": "{{router}} offline",
135140
"description": "Sin respuesta de {{host}}: {{error}}"
136141
},
@@ -429,6 +434,7 @@
429434
"nameHint": "Vacío = nombre automático (hostname DHCP)",
430435
"type": "Tipo",
431436
"icon": "Icono",
437+
"notifyOffline": "Avisar si se desconecta",
432438
"auto": "Auto",
433439
"save": "Guardar",
434440
"saved": "Dispositivo actualizado",

‎app/src/components/DeviceEditSheet.tsx‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
SheetHeader,
1818
SheetTitle,
1919
} from '@/components/ui/sheet'
20+
import { Switch } from '@/components/ui/switch'
2021
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
2122
import { cn, fetchJson } from '@/lib/utils'
2223
import { useNetPulse } from '@/data/DataProvider'
@@ -35,7 +36,7 @@ export interface DeviceEditSheetProps {
3536
saving?: boolean
3637
onClose: () => void
3738
/** Patch absoluto: '' en un campo = volver al valor automático (#797). */
38-
onSave: (device: ClientDevice, patch: { icon: string; name: string; type: string }) => void
39+
onSave: (device: ClientDevice, patch: { icon: string; name: string; type: string; notifyOffline: boolean }) => void
3940
/** #1145: tras borrar el cliente del registro (el padre cierra y refresca). */
4041
onDeleted?: () => void
4142
/** #1151: resto de clientes (para el selector de enlace). */
@@ -69,6 +70,7 @@ export function DeviceEditSheet({
6970
const [linkTarget, setLinkTarget] = useState('')
7071
const [linkBusy, setLinkBusy] = useState(false)
7172
const [icon, setIcon] = useState(device?.iconOverride ?? '')
73+
const [notifyOffline, setNotifyOffline] = useState(device?.notifyOffline ?? false)
7274

7375
const otherClients = (clients ?? []).filter(
7476
(c) => c.mac && c.mac !== device?.mac && c.mac !== device?.id && !(device?.aliasMacs ?? []).includes(c.mac),
@@ -139,8 +141,9 @@ export function DeviceEditSheet({
139141
setIcon(device?.iconOverride ?? '')
140142
setName(device?.nameOverride ?? '')
141143
setDevType(device?.typeOverride ?? '')
144+
setNotifyOffline(device?.notifyOffline ?? false)
142145
setReserveDraft(device?.ip ?? '')
143-
}, [device?.id, device?.iconOverride, device?.nameOverride, device?.typeOverride, device?.ip])
146+
}, [device?.id, device?.iconOverride, device?.nameOverride, device?.typeOverride, device?.notifyOffline, device?.ip])
144147

145148
useEffect(() => {
146149
if (!device || isDemo) return
@@ -171,7 +174,7 @@ export function DeviceEditSheet({
171174

172175
const handleSave = () => {
173176
if (!device) return
174-
onSave(device, { icon, name: name.trim(), type: devType })
177+
onSave(device, { icon, name: name.trim(), type: devType, notifyOffline })
175178
}
176179

177180
// #800: hostname DNS derivado del nombre visible (input del sheet o nombre
@@ -272,6 +275,17 @@ export function DeviceEditSheet({
272275
</div>
273276
</div>
274277

278+
{/* #1354: alerta opt-in cuando el dispositivo se desconecta */}
279+
<div className="flex items-center justify-between rounded-xl border border-border bg-elevated/40 p-3">
280+
<label className="text-label uppercase text-text-muted">{t('devices.edit.notifyOffline')}</label>
281+
<Switch
282+
checked={notifyOffline}
283+
onCheckedChange={setNotifyOffline}
284+
disabled={saving}
285+
aria-label={t('devices.edit.notifyOffline')}
286+
/>
287+
</div>
288+
275289
{/* #1096: Save/Cancel junto a la zona que editan (nombre, tipo,
276290
icono); abajo quedaban lejos, tras Reserva/Bloqueo, que son
277291
acciones independientes con sus propios botones. */}

‎app/src/components/routers/RouterClients.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface LocalOverride {
2626
icon?: string
2727
name?: string
2828
type?: string
29+
notifyOffline?: boolean
2930
}
3031

3132
/** ⑦ Clientes de este router (router-detail.md §⑦). */
@@ -61,6 +62,7 @@ export function RouterClients({ router }: { router: Router }) {
6162
type: (ov.type || d.type) as ClientDevice['type'],
6263
nameOverride: ov.name || d.nameOverride,
6364
typeOverride: ov.type || d.typeOverride,
65+
notifyOffline: ov.notifyOffline ?? d.notifyOffline,
6466
}
6567
})
6668
// #827: los sin identificar (nombre = MAC) son los accionables: se
@@ -92,9 +94,9 @@ export function RouterClients({ router }: { router: Router }) {
9294
const toggleSort = (key: SortKey) =>
9395
setSort((prev) => (prev.key === key ? { key, dir: prev.dir === 1 ? -1 : 1 } : { key, dir: 1 }))
9496

95-
const handleEditSave = async (device: ClientDevice, patch: { icon: string; name: string; type: string }) => {
97+
const handleEditSave = async (device: ClientDevice, patch: { icon: string; name: string; type: string; notifyOffline: boolean }) => {
9698
if (isDemo) {
97-
setOverrides((prev) => ({ ...prev, [device.id]: { icon: patch.icon, name: patch.name, type: patch.type } }))
99+
setOverrides((prev) => ({ ...prev, [device.id]: { icon: patch.icon, name: patch.name, type: patch.type, notifyOffline: patch.notifyOffline } }))
98100
setEditingId(null)
99101
setToast(t('devices.edit.saved'))
100102
return
@@ -103,7 +105,7 @@ export function RouterClients({ router }: { router: Router }) {
103105
const res = await fetchJson(`/api/devices/${encodeURIComponent(device.mac)}/override`, {
104106
method: 'PUT',
105107
headers: { 'Content-Type': 'application/json' },
106-
body: JSON.stringify({ icon: patch.icon, name: patch.name, type: patch.type }),
108+
body: JSON.stringify({ icon: patch.icon, name: patch.name, type: patch.type, notifyOffline: patch.notifyOffline }),
107109
})
108110
setSaving(false)
109111
if (!res.ok) {

‎app/src/data/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ export interface Device {
253253
nameOverride?: string
254254
/** Override manual del tipo (#797). El efectivo viaja en `type`. */
255255
typeOverride?: string
256+
/** #1354: avisar cuando este dispositivo se desconecta (opt-in). */
257+
notifyOffline?: boolean
256258
/**
257259
* Epoch ms de la primera/última vez que el server vio online al cliente
258260
* (#954, tabla device_seen). Ausente/0 = desconocido; la app formatea el

‎app/src/pages/Devices.tsx‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,10 +1422,10 @@ export default function Devices() {
14221422
// fallo silencioso: la sugerencia sigue para reintentar
14231423
}
14241424
}
1425-
const [deviceOverrides, setDeviceOverrides] = useState<Record<string, { iconOverride?: string; nameOverride?: string; typeOverride?: string }>>(() => {
1425+
const [deviceOverrides, setDeviceOverrides] = useState<Record<string, { iconOverride?: string; nameOverride?: string; typeOverride?: string; notifyOffline?: boolean }>>(() => {
14261426
try {
14271427
const raw = localStorage.getItem('netpulse-device-overrides')
1428-
return raw ? (JSON.parse(raw) as Record<string, { iconOverride?: string; nameOverride?: string; typeOverride?: string }>) : {}
1428+
return raw ? (JSON.parse(raw) as Record<string, { iconOverride?: string; nameOverride?: string; typeOverride?: string; notifyOffline?: boolean }>) : {}
14291429
} catch {
14301430
return {}
14311431
}
@@ -1447,6 +1447,7 @@ export default function Devices() {
14471447
type: (ov?.typeOverride || d.type) as ClientDevice['type'],
14481448
nameOverride: ov?.nameOverride || d.nameOverride,
14491449
typeOverride: ov?.typeOverride || d.typeOverride,
1450+
notifyOffline: ov?.notifyOffline ?? d.notifyOffline,
14501451
}
14511452
})
14521453
const hosts = new Set(
@@ -1753,14 +1754,15 @@ export default function Devices() {
17531754
const [savingOverride, setSavingOverride] = useState(false)
17541755

17551756
const persistOverride = useCallback(
1756-
(id: string, patch: { iconOverride: string; nameOverride: string; typeOverride: string } | null) => {
1757+
(id: string, patch: { iconOverride: string; nameOverride: string; typeOverride: string; notifyOffline: boolean } | null) => {
17571758
setDeviceOverrides((prev) => {
17581759
const next = { ...prev }
1759-
if (patch && (patch.iconOverride || patch.nameOverride || patch.typeOverride)) {
1760+
if (patch && (patch.iconOverride || patch.nameOverride || patch.typeOverride || patch.notifyOffline)) {
17601761
next[id] = {
17611762
iconOverride: patch.iconOverride,
17621763
nameOverride: patch.nameOverride,
17631764
typeOverride: patch.typeOverride,
1765+
notifyOffline: patch.notifyOffline,
17641766
}
17651767
} else {
17661768
delete next[id]
@@ -1780,19 +1782,20 @@ export default function Devices() {
17801782
// return de "sin cambios" impedía limpiar un override (p. ej. volver al
17811783
// icono Auto).
17821784
const handleEditSave = useCallback(
1783-
async (device: ClientDevice, patch: { icon: string; name: string; type: string }) => {
1785+
async (device: ClientDevice, patch: { icon: string; name: string; type: string; notifyOffline: boolean }) => {
17841786
persistOverride(device.id, {
17851787
iconOverride: patch.icon,
17861788
nameOverride: patch.name,
17871789
typeOverride: patch.type,
1790+
notifyOffline: patch.notifyOffline,
17881791
})
17891792

17901793
if (!isDemo) {
17911794
setSavingOverride(true)
17921795
const res = await fetchJson(`/api/devices/${encodeURIComponent(device.mac)}/override`, {
17931796
method: 'PUT',
17941797
headers: { 'Content-Type': 'application/json' },
1795-
body: JSON.stringify({ icon: patch.icon, name: patch.name, type: patch.type }),
1798+
body: JSON.stringify({ icon: patch.icon, name: patch.name, type: patch.type, notifyOffline: patch.notifyOffline }),
17961799
})
17971800
setSavingOverride(false)
17981801
if (!res.ok) {

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,12 +242,12 @@ func TestLiveRouterReconcileRemovesOrphan(t *testing.T) {
242242
// y una de OTRO router que no responde en este ciclo.
243243
l.engine.Emit(alerts.AlertEvent{
244244
ID: "alert-offline-patio-111", Category: alerts.CatRouter, Urgent: true,
245-
Severity: "critical", Title: "Patio offline", Type: alerts.HintDeviceOffline,
245+
Severity: "critical", Title: "Patio offline", Type: alerts.TypeRouterOffline,
246246
RouterID: "patio",
247247
})
248248
l.engine.Emit(alerts.AlertEvent{
249249
ID: "alert-offline-living-222", Category: alerts.CatRouter, Urgent: true,
250-
Severity: "critical", Title: "Living offline", Type: alerts.HintDeviceOffline,
250+
Severity: "critical", Title: "Living offline", Type: alerts.TypeRouterOffline,
251251
RouterID: "living",
252252
})
253253
if n := len(l.engine.List()); n != 2 {
@@ -285,14 +285,14 @@ func TestLiveRouterOfflineOpenReplacesOrphans(t *testing.T) {
285285
}
286286
l.engine.Emit(alerts.AlertEvent{
287287
ID: "alert-offline-patio-111", Category: alerts.CatRouter, Urgent: true,
288-
Severity: "critical", Title: "Patio offline", Type: alerts.HintDeviceOffline,
288+
Severity: "critical", Title: "Patio offline", Type: alerts.TypeRouterOffline,
289289
RouterID: "patio",
290290
})
291291
// Segunda huérfana con la misma key de dedup: solo entra sin dedup (es el
292292
// shape real tras reinicios seguidos durante una caída larga).
293293
l.engine.EmitNoDedup(alerts.AlertEvent{
294294
ID: "alert-offline-patio-222", Category: alerts.CatRouter, Urgent: true,
295-
Severity: "critical", Title: "Patio offline", Type: alerts.HintDeviceOffline,
295+
Severity: "critical", Title: "Patio offline", Type: alerts.TypeRouterOffline,
296296
RouterID: "patio",
297297
})
298298
cfg := &RouterConfig{ID: "patio", Name: "Patio", Host: "10.0.0.1"}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// device_offline_alert_test.go - issue #1354: alerta opt-in cuando un
2+
// dispositivo marcado se desconecta (volátil, ID estable, gracia de 3 ticks).
3+
package adapters
4+
5+
import (
6+
"testing"
7+
8+
"github.com/gnacho/netpulse/server-go/internal/alerts"
9+
)
10+
11+
func offlineAlertTestLive() *Live {
12+
return &Live{
13+
engine: alerts.New(nil, nil),
14+
routers: []RouterConfig{{ID: "rt1", Name: "Salón"}},
15+
offlineOnline: map[string]bool{},
16+
offlineMisses: map[string]int{},
17+
offlineAlerted: map[string]bool{},
18+
offlineAlertNum: 3,
19+
}
20+
}
21+
22+
func offlineMarkedDevice() Device {
23+
return Device{MAC: "AA:BB:CC:DD:EE:01", Name: "TV Salón", RouterID: "rt1", NotifyOffline: true, Online: true}
24+
}
25+
26+
// (a) Dispositivo marcado offline N ciclos: una única emisión con ID estable.
27+
func TestTrackOfflineDevicesEmitsOnceAfterGrace(t *testing.T) {
28+
l := offlineAlertTestLive()
29+
d := offlineMarkedDevice()
30+
l.trackOfflineDevices([]Device{d}) // línea base online
31+
d.Online = false
32+
l.trackOfflineDevices([]Device{d}) // miss 1
33+
l.trackOfflineDevices([]Device{d}) // miss 2
34+
if n := len(l.engine.List()); n != 0 {
35+
t.Fatalf("aún en gracia: %d alertas", n)
36+
}
37+
l.trackOfflineDevices([]Device{d}) // miss 3: emite
38+
list := l.engine.List()
39+
if len(list) != 1 {
40+
t.Fatalf("esperaba 1 alerta, got %d", len(list))
41+
}
42+
ev := list[0]
43+
if ev.ID != "alert-device-offline-"+d.MAC {
44+
t.Fatalf("ID=%q", ev.ID)
45+
}
46+
if ev.Type != alerts.TypeDeviceOffline {
47+
t.Fatalf("Type=%q (want %q)", ev.Type, alerts.TypeDeviceOffline)
48+
}
49+
if ev.Category != alerts.CatClients || !ev.Urgent || ev.Severity != "warn" {
50+
t.Fatalf("taxonomía: (%s,%v,%s)", ev.Category, ev.Urgent, ev.Severity)
51+
}
52+
if ev.Vars["device"] != "TV Salón" || ev.Vars["router"] != "Salón" {
53+
t.Fatalf("Vars=%+v", ev.Vars)
54+
}
55+
// Un tick más offline: el ID estable no re-emite (sigue 1 entrada).
56+
l.trackOfflineDevices([]Device{d})
57+
if len(l.engine.List()) != 1 {
58+
t.Fatalf("re-emitió: %d alertas", len(l.engine.List()))
59+
}
60+
}
61+
62+
// (b) Vuelve online: la entrada volátil desaparece del feed.
63+
func TestTrackOfflineDevicesResolvesOnRecovery(t *testing.T) {
64+
l := offlineAlertTestLive()
65+
d := offlineMarkedDevice()
66+
l.trackOfflineDevices([]Device{d})
67+
d.Online = false
68+
l.trackOfflineDevices([]Device{d})
69+
l.trackOfflineDevices([]Device{d})
70+
l.trackOfflineDevices([]Device{d})
71+
if len(l.engine.List()) != 1 {
72+
t.Fatalf("previo: %d alertas", len(l.engine.List()))
73+
}
74+
d.Online = true
75+
l.trackOfflineDevices([]Device{d})
76+
if n := len(l.engine.List()); n != 0 {
77+
t.Fatalf("la alerta debió retirarse al volver online, quedaron %d: %+v", n, l.engine.List())
78+
}
79+
}
80+
81+
// (c) Dispositivo SIN marca: ninguna emisión.
82+
func TestTrackOfflineDevicesUnmarkedNeverEmits(t *testing.T) {
83+
l := offlineAlertTestLive()
84+
d := offlineMarkedDevice()
85+
d.NotifyOffline = false
86+
l.trackOfflineDevices([]Device{d})
87+
d.Online = false
88+
for i := 0; i < 6; i++ {
89+
l.trackOfflineDevices([]Device{d})
90+
}
91+
if n := len(l.engine.List()); n != 0 {
92+
t.Fatalf("sin marca no debía emitir: %+v", l.engine.List())
93+
}
94+
}
95+
96+
// (d) Ya offline al arrancar: sin transición online->offline, nada.
97+
func TestTrackOfflineDevicesAlreadyOfflineAtBootNeverEmits(t *testing.T) {
98+
l := offlineAlertTestLive()
99+
d := offlineMarkedDevice()
100+
d.Online = false
101+
for i := 0; i < 6; i++ {
102+
l.trackOfflineDevices([]Device{d})
103+
}
104+
if n := len(l.engine.List()); n != 0 {
105+
t.Fatalf("una MAC offline desde el arranque no debía emitir: %+v", l.engine.List())
106+
}
107+
}
108+
109+
// (e) Offline 2 ciclos y vuelve (dentro de la gracia): nada.
110+
func TestTrackOfflineDevicesFlapWithinGraceNoEmit(t *testing.T) {
111+
l := offlineAlertTestLive()
112+
d := offlineMarkedDevice()
113+
l.trackOfflineDevices([]Device{d}) // línea base online
114+
d.Online = false
115+
l.trackOfflineDevices([]Device{d}) // miss 1
116+
l.trackOfflineDevices([]Device{d}) // miss 2
117+
d.Online = true
118+
l.trackOfflineDevices([]Device{d}) // vuelve dentro de la gracia
119+
if n := len(l.engine.List()); n != 0 {
120+
t.Fatalf("un flap dentro de la gracia no debía emitir: %+v", l.engine.List())
121+
}
122+
l.trackOfflineDevices([]Device{d})
123+
if n := len(l.engine.List()); n != 0 {
124+
t.Fatalf("restos tras la recuperación: %+v", l.engine.List())
125+
}
126+
}

0 commit comments

Comments
 (0)