Skip to content

Commit a3bd10a

Browse files
committed
webui: firmware — SSE progress, auto-reboot, URL history
Replace browser-side HTMX polling with a Go SSE endpoint that polls RESTCONF internally and streams rendered HTML fragments. This fixes the "Could not fetch firmware status" crash that occurred when the RESTCONF server timed out during an upgrade. - GET /firmware/progress: streams fw-progress-body fragments as SSE; events: progress (update), done (success/failure), reboot (auto-reboot) - Go server polls every second with change detection to suppress redundant frames; RESTCONF timeouts leave Installer nil so the template renders "Installing..." without crashing - Auto-reboot checkbox: triggers a reboot after successful install; JS handles the reboot SSE event and shows the reboot overlay - URL history datalist: remembers the last 10 firmware URLs in localStorage and offers them as autocomplete suggestions - Install complete / failed: persistent card replaces the progress bar with a green check or red X; includes Reboot to activate button when auto-reboot was not selected - Reboot overlay: simplify to a fixed 4 s initial grace period before polling (avoids fast-reboot race where sawDown was never set); 5 s DeviceStatus timeout keeps polls snappy; window.location.replace avoids stale firmware history entries - Server-side guard: clears Installing flag when installer is already Done so navigating back to /firmware?installing=1 after a reboot does not re-open the SSE card or trigger a second reboot - color-scheme: light/dark on .light/.dark fixes native checkbox rendering in forced-theme mode Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
1 parent ce6a699 commit a3bd10a

5 files changed

Lines changed: 332 additions & 86 deletions

File tree

src/webui/internal/handlers/system.go

Lines changed: 135 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
package handlers
44

55
import (
6+
"bytes"
7+
"context"
68
"fmt"
79
"html/template"
810
"log"
911
"net/http"
12+
"strings"
13+
"time"
1014

1115
"github.com/kernelkit/webui/internal/restconf"
1216
)
@@ -19,9 +23,12 @@ type SystemHandler struct {
1923

2024
// DeviceStatus returns 200 if the RESTCONF device is reachable, 502 otherwise.
2125
// Used by the reboot spinner to detect when the device goes down and comes back.
26+
// A short timeout keeps the poll snappy during the reboot window.
2227
func (h *SystemHandler) DeviceStatus(w http.ResponseWriter, r *http.Request) {
28+
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
29+
defer cancel()
2330
var target struct{}
24-
err := h.RC.Get(r.Context(), "/data/ietf-system:system-state/platform", &target)
31+
err := h.RC.Get(ctx, "/data/ietf-system:system-state/platform", &target)
2532
if err != nil {
2633
w.WriteHeader(http.StatusBadGateway)
2734
return
@@ -43,7 +50,7 @@ func (h *SystemHandler) Reboot(w http.ResponseWriter, r *http.Request) {
4350
fmt.Fprint(w, rebootSpinnerHTML)
4451
}
4552

46-
const rebootSpinnerHTML = `<div class="reboot-overlay" data-timeout="120000" data-interval="2000">
53+
const rebootSpinnerHTML = `<div class="reboot-overlay">
4754
<div class="reboot-spinner"></div>
4855
<p class="reboot-message">Rebooting&hellip;</p>
4956
<p class="reboot-status" id="reboot-status">Waiting for device to shut down&hellip;</p>
@@ -118,6 +125,8 @@ type firmwareData struct {
118125
BootOrder []string
119126
Slots []slotEntry
120127
Installer *installerEntry
128+
Installing bool // install was triggered this session; keep card visible during RAUC phase gaps
129+
AutoReboot bool
121130
Error string
122131
Message string
123132
}
@@ -136,13 +145,17 @@ type installerEntry struct {
136145
Message string
137146
LastError string
138147
Active bool
148+
Done bool // idle after an install ran (percentage>0 or error set)
149+
Success bool // Done with no error
139150
}
140151

141152
// Firmware renders the firmware overview page (GET /firmware).
142153
func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) {
143154
data := firmwareData{
144-
PageData: newPageData(r, "firmware", "Firmware"),
145-
Message: r.URL.Query().Get("msg"),
155+
PageData: newPageData(r, "firmware", "Firmware"),
156+
Message: r.URL.Query().Get("msg"),
157+
Installing: r.URL.Query().Get("installing") == "1",
158+
AutoReboot: r.URL.Query().Get("auto-reboot") == "1",
146159
}
147160

148161
var sw fwSoftwareWrapper
@@ -177,13 +190,11 @@ func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) {
177190
})
178191
}
179192

180-
inst := sw.SystemState.Software.Installer
181-
data.Installer = &installerEntry{
182-
Operation: inst.Operation,
183-
Percentage: inst.Progress.Percentage,
184-
Message: inst.Progress.Message,
185-
LastError: inst.LastError,
186-
Active: inst.Operation != "" && inst.Operation != "idle",
193+
data.Installer = newInstallerEntry(sw.SystemState.Software.Installer)
194+
// Don't re-open the SSE progress card for an already-finished install
195+
// (e.g. user navigating back to /firmware?installing=1 after reboot).
196+
if data.Installer.Done {
197+
data.Installing = false
187198
}
188199
}
189200

@@ -224,6 +235,118 @@ func (h *SystemHandler) FirmwareInstall(w http.ResponseWriter, r *http.Request)
224235
return
225236
}
226237

227-
w.Header().Set("HX-Redirect", "/firmware?msg=Install+started")
238+
target := "/firmware?installing=1"
239+
if r.FormValue("auto-reboot") == "1" {
240+
target += "&auto-reboot=1"
241+
}
242+
w.Header().Set("HX-Redirect", target)
228243
w.WriteHeader(http.StatusNoContent)
229244
}
245+
246+
// FirmwareProgress streams installer status as SSE so the Go server does the
247+
// polling and the browser just receives rendered HTML fragments.
248+
// GET /firmware/progress
249+
func (h *SystemHandler) FirmwareProgress(w http.ResponseWriter, r *http.Request) {
250+
flusher, ok := w.(http.Flusher)
251+
if !ok {
252+
http.Error(w, "streaming not supported", http.StatusInternalServerError)
253+
return
254+
}
255+
256+
autoReboot := r.URL.Query().Get("auto-reboot") == "1"
257+
258+
w.Header().Set("Content-Type", "text/event-stream")
259+
w.Header().Set("Cache-Control", "no-cache")
260+
w.Header().Set("X-Accel-Buffering", "no")
261+
w.WriteHeader(http.StatusOK)
262+
flusher.Flush()
263+
264+
ticker := time.NewTicker(time.Second)
265+
defer ticker.Stop()
266+
267+
var lastKey string // change-detection: suppress redundant SSE frames
268+
269+
for {
270+
select {
271+
case <-r.Context().Done():
272+
return
273+
case <-ticker.C:
274+
data := h.installerSnapshot(r, autoReboot)
275+
276+
// Build a cheap key for change detection; skip frames with identical state.
277+
var key string
278+
if data.Installer != nil {
279+
key = fmt.Sprintf("%s|%d|%s|%s", data.Installer.Operation, data.Installer.Percentage, data.Installer.Message, data.Installer.LastError)
280+
}
281+
if key == lastKey && key != "" {
282+
continue
283+
}
284+
lastKey = key
285+
286+
var buf bytes.Buffer
287+
if err := h.Template.ExecuteTemplate(&buf, "fw-progress-body", data); err != nil {
288+
log.Printf("firmware progress template: %v", err)
289+
continue
290+
}
291+
292+
// SSE data must not contain raw newlines; collapse to spaces.
293+
line := strings.ReplaceAll(buf.String(), "\n", " ")
294+
295+
eventName := "progress"
296+
if data.Installer != nil && data.Installer.Done {
297+
if autoReboot && data.Installer.Success {
298+
eventName = "reboot"
299+
} else {
300+
eventName = "done"
301+
}
302+
}
303+
304+
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventName, line)
305+
flusher.Flush()
306+
307+
if data.Installer != nil && data.Installer.Done {
308+
return
309+
}
310+
}
311+
}
312+
}
313+
314+
// installerSnapshot fetches the current installer state from RESTCONF and
315+
// builds the template data for the fw-progress-body fragment.
316+
func (h *SystemHandler) installerSnapshot(r *http.Request, autoReboot bool) firmwareProgressData {
317+
data := firmwareProgressData{
318+
AutoReboot: autoReboot,
319+
}
320+
321+
var sw fwSoftwareWrapper
322+
if err := h.RC.Get(r.Context(), "/data/ietf-system:system-state", &sw); err != nil {
323+
// RESTCONF temporarily unavailable during upgrade — leave Installer nil
324+
// so the template renders an indeterminate "Installing…" state.
325+
log.Printf("firmware progress poll: %v", err)
326+
return data
327+
}
328+
329+
data.Installer = newInstallerEntry(sw.SystemState.Software.Installer)
330+
return data
331+
}
332+
333+
// newInstallerEntry converts a raw YANG installer state to the template-facing struct.
334+
func newInstallerEntry(inst fwInstallerState) *installerEntry {
335+
idle := inst.Operation == "" || inst.Operation == "idle"
336+
done := idle && (inst.Progress.Percentage > 0 || inst.LastError != "")
337+
return &installerEntry{
338+
Operation: inst.Operation,
339+
Percentage: inst.Progress.Percentage,
340+
Message: inst.Progress.Message,
341+
LastError: inst.LastError,
342+
Active: !idle,
343+
Done: done,
344+
Success: done && inst.LastError == "",
345+
}
346+
}
347+
348+
// firmwareProgressData is the template data for the fw-progress-body fragment.
349+
type firmwareProgressData struct {
350+
AutoReboot bool
351+
Installer *installerEntry
352+
}

src/webui/internal/server/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ func New(
168168
mux.HandleFunc("GET /firewall", fw.Overview)
169169
mux.HandleFunc("GET /keystore", ks.Overview)
170170
mux.HandleFunc("GET /firmware", sys.Firmware)
171+
mux.HandleFunc("GET /firmware/progress", sys.FirmwareProgress)
171172
mux.HandleFunc("POST /firmware/install", sys.FirmwareInstall)
172173
mux.HandleFunc("POST /reboot", sys.Reboot)
173174
mux.HandleFunc("GET /device-status", sys.DeviceStatus)

src/webui/static/css/style.css

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888

8989
/* Manual Dark Mode Toggle */
9090
.dark {
91+
color-scheme: dark;
9192
--bg: var(--slate-900);
9293
--surface: var(--slate-800);
9394
--fg: var(--slate-100);
@@ -106,6 +107,7 @@
106107

107108
/* Force Light Mode (override system dark preference) */
108109
.light {
110+
color-scheme: light;
109111
--bg: var(--slate-50);
110112
--surface: #ffffff;
111113
--fg: var(--slate-900);
@@ -1525,12 +1527,36 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
15251527
font-size: 0.875rem;
15261528
}
15271529

1528-
.firmware-form { display: flex; gap: 1rem; align-items: flex-end; }
1529-
.firmware-form .form-group { flex: 1; margin-bottom: 0; }
1530+
.firmware-form .form-group { margin-bottom: 0.75rem; }
1531+
.firmware-form .fw-checkbox-row { margin-bottom: 0.75rem; }
15301532

1531-
.badge-neutral {
1533+
.fw-checkbox-row {
1534+
display: flex;
1535+
align-items: center;
1536+
gap: 0.5rem;
1537+
font-size: 0.875rem;
1538+
color: var(--fg-muted);
1539+
cursor: pointer;
1540+
user-select: none;
1541+
}
1542+
.fw-checkbox-row input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; }
1543+
1544+
.fw-result {
1545+
display: flex;
1546+
align-items: center;
1547+
gap: 0.75rem;
1548+
font-size: 0.9rem;
1549+
font-weight: 500;
1550+
}
1551+
.fw-result svg { flex-shrink: 0; }
1552+
.fw-result-ok { color: var(--success); }
1553+
.fw-result-err { color: var(--danger); }
1554+
.fw-result-body { display: flex; flex-direction: column; }
1555+
.fw-result-actions { margin-top: 1rem; }
1556+
1557+
/* Compact pill badges used in the firmware slot list and boot-order row. */
1558+
.fw-install-grid .badge-neutral {
15321559
background: var(--slate-200);
1533-
color: var(--slate-600);
15341560
font-size: 0.65rem;
15351561
font-weight: 600;
15361562
padding: 0.15em 0.5em;
@@ -1540,9 +1566,11 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
15401566
vertical-align: middle;
15411567
}
15421568
@media (prefers-color-scheme: dark) {
1543-
.badge-neutral { background: var(--slate-700); color: var(--slate-300); }
1569+
.fw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); }
15441570
}
1545-
.dark .badge-neutral { background: var(--slate-700); color: var(--slate-300); }
1571+
.dark .fw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); }
1572+
1573+
.progress-bar-wrap--flush { margin-top: 0; }
15461574

15471575
.progress-bar-wrap {
15481576
background: var(--slate-200);

0 commit comments

Comments
 (0)