Skip to content

Commit 9a4d79a

Browse files
authored
feat(firmware): dismiss stale failed upgrade notice (#519) (#521)
Co-authored-by: gnacho <hnacho@proton.me>
1 parent ac08aa1 commit 9a4d79a

7 files changed

Lines changed: 143 additions & 11 deletions

File tree

‎app/node_modules‎

Lines changed: 0 additions & 1 deletion
This file was deleted.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1448,7 +1448,8 @@
14481448
"flashing": "Flashing",
14491449
"done": "Done",
14501450
"failed": "Failed"
1451-
}
1451+
},
1452+
"dismiss": "Dismiss notice"
14521453
},
14531454
"orchestration": {
14541455
"title": "Orchestration",

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1448,7 +1448,8 @@
14481448
"flashing": "Flasheando",
14491449
"done": "Completada",
14501450
"failed": "Fallida"
1451-
}
1451+
},
1452+
"dismiss": "Descartar aviso"
14521453
},
14531454
"orchestration": {
14541455
"title": "Orquestación",

‎app/src/pages/FirmwareUpgrades.tsx‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,20 @@ export default function FirmwareUpgrades() {
132132
}
133133
}
134134

135+
// #519: descartar el aviso de un intento de upgrade fallido/obsoleto.
136+
const dismissUpgrade = async (id: string) => {
137+
setBusy((prev) => ({ ...prev, [id]: 'dismiss' }))
138+
try {
139+
const res = await fetch(`/api/firmware-upgrades/${encodeURIComponent(id)}/failure`, { method: 'DELETE' })
140+
if (!res.ok) throw new Error(await res.text())
141+
await fetchItems()
142+
} catch (err) {
143+
setError(String(err))
144+
} finally {
145+
setBusy((prev) => ({ ...prev, [id]: '' }))
146+
}
147+
}
148+
135149
const startUpgrade = async (id: string) => {
136150
setBusy((prev) => ({ ...prev, [id]: 'upgrade' }))
137151
try {
@@ -304,12 +318,23 @@ export default function FirmwareUpgrades() {
304318
)}
305319

306320
{item.upgrade?.error && (
307-
<div className="mt-4 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-600 dark:text-rose-400">
321+
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-600 dark:text-rose-400">
308322
{/* Timestamp del fallo: un error antiguo NO debe leerse como
309323
estado presente del agente (feedback #477). */}
310-
{t('firmwareUpgrades.lastFailure')}
311-
{relTimeFromTs(item.upgrade.startedAt) ? ` (${relTimeFromTs(item.upgrade.startedAt)})` : ''}:{' '}
312-
{item.upgrade.error}
324+
<span>
325+
{t('firmwareUpgrades.lastFailure')}
326+
{relTimeFromTs(item.upgrade.startedAt) ? ` (${relTimeFromTs(item.upgrade.startedAt)})` : ''}:{' '}
327+
{item.upgrade.error}
328+
</span>
329+
{isAdmin && (
330+
<button
331+
onClick={() => void dismissUpgrade(item.routerId)}
332+
disabled={busy[item.routerId] === 'dismiss' || active}
333+
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg border border-rose-500/40 px-2.5 text-xs font-medium transition-colors hover:bg-rose-500/10 disabled:opacity-50"
334+
>
335+
{busy[item.routerId] === 'dismiss' ? t('common.loading') : t('firmwareUpgrades.dismiss')}
336+
</button>
337+
)}
313338
</div>
314339
)}
315340

‎server-go/internal/firmware/store.go‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,14 @@ func (s *Store) SetStatus(id int64, status, errMsg, backupPath string) error {
124124
func (s *Store) LatestUpgrade(routerID string) (*Upgrade, error) {
125125
var u Upgrade
126126
var finished sql.NullInt64
127+
var errStr, backup sql.NullString
127128
err := s.db.QueryRow(`
128129
SELECT id, router_id, target_version, target_url, checksum, status, error, backup_path, started_at, finished_at
129130
FROM firmware_upgrades
130131
WHERE router_id = ?
131132
ORDER BY started_at DESC
132133
LIMIT 1
133-
`, routerID).Scan(&u.ID, &u.RouterID, &u.TargetVersion, &u.TargetURL, &u.Checksum, &u.Status, &u.Error, &u.BackupPath, &u.StartedAt, &finished)
134+
`, routerID).Scan(&u.ID, &u.RouterID, &u.TargetVersion, &u.TargetURL, &u.Checksum, &u.Status, &errStr, &backup, &u.StartedAt, &finished)
134135
if err == sql.ErrNoRows {
135136
return nil, nil
136137
}
@@ -140,25 +141,54 @@ func (s *Store) LatestUpgrade(routerID string) (*Upgrade, error) {
140141
if finished.Valid {
141142
u.FinishedAt = &finished.Int64
142143
}
144+
if errStr.Valid {
145+
u.Error = errStr.String
146+
}
147+
if backup.Valid {
148+
u.BackupPath = backup.String
149+
}
143150
return &u, nil
144151
}
145152

146153
// GetUpgradeByID busca un upgrade por ID (para validar propiedad del agente).
147154
func (s *Store) GetUpgradeByID(id int64) (*Upgrade, error) {
148155
var u Upgrade
149156
var finished sql.NullInt64
157+
var errStr, backup sql.NullString
150158
err := s.db.QueryRow(`
151159
SELECT id, router_id, target_version, target_url, checksum, status, error, backup_path, started_at, finished_at
152160
FROM firmware_upgrades WHERE id = ?
153-
`, id).Scan(&u.ID, &u.RouterID, &u.TargetVersion, &u.TargetURL, &u.Checksum, &u.Status, &u.Error, &u.BackupPath, &u.StartedAt, &finished)
161+
`, id).Scan(&u.ID, &u.RouterID, &u.TargetVersion, &u.TargetURL, &u.Checksum, &u.Status, &errStr, &backup, &u.StartedAt, &finished)
154162
if err == sql.ErrNoRows {
155163
return nil, nil
156164
}
157165
if err != nil {
158166
return nil, err
159167
}
168+
if errStr.Valid {
169+
u.Error = errStr.String
170+
}
171+
if backup.Valid {
172+
u.BackupPath = backup.String
173+
}
160174
if finished.Valid {
161175
u.FinishedAt = &finished.Int64
162176
}
163177
return &u, nil
164178
}
179+
180+
// DismissLatest borra el último intento de upgrade del router si está
181+
// terminado (failed o done). Un upgrade en curso (requested/running) no se
182+
// toca: el aviso de error obsoleto debe poder descartarse (#519) sin poder
183+
// cancelar una operación viva por accidente.
184+
func (s *Store) DismissLatest(routerID string) error {
185+
up, err := s.LatestUpgrade(routerID)
186+
if err != nil {
187+
return err
188+
}
189+
if up == nil || (up.Status != "failed" && up.Status != "done") {
190+
return nil
191+
}
192+
_, err = s.db.Exec(`DELETE FROM firmware_upgrades WHERE id = ?`, up.ID)
193+
return err
194+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// store_test.go — contrato del store de firmware (issue #519: descarte del
2+
// último intento fallido).
3+
package firmware
4+
5+
import (
6+
"testing"
7+
8+
"github.com/gnacho/netpulse/server-go/internal/db"
9+
)
10+
11+
func open(t *testing.T) *Store {
12+
t.Helper()
13+
d, err := db.Open(t.TempDir())
14+
if err != nil {
15+
t.Fatalf("db: %v", err)
16+
}
17+
t.Cleanup(func() { d.Close() })
18+
return NewStore(d.DB)
19+
}
20+
21+
func TestDismissLatestFailed(t *testing.T) {
22+
s := open(t)
23+
id, err := s.BeginUpgrade("rt1", "v2", "http://x", "abc")
24+
if err != nil {
25+
t.Fatalf("begin: %v", err)
26+
}
27+
if err := s.SetStatus(id, "failed", "agent not connected", ""); err != nil {
28+
t.Fatalf("set failed: %v", err)
29+
}
30+
up, _ := s.LatestUpgrade("rt1")
31+
if up == nil || up.Status != "failed" {
32+
t.Fatalf("esperaba upgrade failed, got %+v", up)
33+
}
34+
if err := s.DismissLatest("rt1"); err != nil {
35+
t.Fatalf("dismiss: %v", err)
36+
}
37+
up, _ = s.LatestUpgrade("rt1")
38+
if up != nil {
39+
t.Fatalf("tras dismiss no debería haber upgrade: %+v", up)
40+
}
41+
}
42+
43+
func TestDismissLatestKeepsRunning(t *testing.T) {
44+
s := open(t)
45+
id, err := s.BeginUpgrade("rt1", "v2", "http://x", "abc")
46+
if err != nil {
47+
t.Fatalf("begin: %v", err)
48+
}
49+
// Estado en curso (requested): el descarte no debe tocarlo.
50+
if err := s.DismissLatest("rt1"); err != nil {
51+
t.Fatalf("dismiss: %v", err)
52+
}
53+
up, _ := s.LatestUpgrade("rt1")
54+
if up == nil || up.ID != id {
55+
t.Fatalf("un upgrade en curso debe sobrevivir al dismiss: %+v", up)
56+
}
57+
// Router sin upgrades: no debe fallar.
58+
if err := s.DismissLatest("no-existe"); err != nil {
59+
t.Fatalf("dismiss sin upgrades: %v", err)
60+
}
61+
}

‎server-go/internal/httpapi/firmware.go‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,23 @@ func (s *server) registerFirmwareRoutes(mux *http.ServeMux) {
9999
writeJSON(w, http.StatusOK, out)
100100
})))
101101

102-
mux.Handle("GET /api/firmware-upgrades/{routerId}", auth.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103-
id := r.PathValue("routerId")
102+
// DELETE /api/firmware-upgrades/{routerId}/failure: descarta el último
103+
// intento de upgrade terminado (failed/done) del router (#519). Permite
104+
// cerrar un aviso de error obsoleto sin poder cancelar un upgrade vivo.
105+
mux.Handle("DELETE /api/firmware-upgrades/{routerId}/failure", auth.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106+
routerID := r.PathValue("routerId")
107+
if routerID == "" {
108+
writeError(w, http.StatusBadRequest, "invalid_input", "routerId requerido")
109+
return
110+
}
111+
if err := s.firmware.DismissLatest(routerID); err != nil {
112+
writeError(w, http.StatusInternalServerError, "firmware_error")
113+
return
114+
}
115+
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
116+
})))
117+
118+
mux.Handle("GET /api/firmware-upgrades/{routerId}", auth.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("routerId")
104119
item, err := s.firmwareStatus(id)
105120
if err != nil {
106121
writeError(w, http.StatusInternalServerError, "firmware_error")

0 commit comments

Comments
 (0)