diff --git a/package/webui/webui-proxy.conf b/package/webui/webui-proxy.conf
new file mode 100644
index 00000000..923990d0
--- /dev/null
+++ b/package/webui/webui-proxy.conf
@@ -0,0 +1,11 @@
+# Shared proxy-pass shape for the webui upstream. Nested locations that
+# declare their own proxy_* directives don't inherit from the outer block,
+# so each location includes this file rather than relying on inheritance.
+
+proxy_pass http://127.0.0.1:10000;
+proxy_http_version 1.1;
+proxy_set_header Host $host;
+proxy_set_header X-Real-IP $remote_addr;
+proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+proxy_set_header X-Forwarded-Proto $scheme;
+proxy_redirect off;
diff --git a/package/webui/webui.conf b/package/webui/webui.conf
index bcedda91..0709fdc4 100644
--- a/package/webui/webui.conf
+++ b/package/webui/webui.conf
@@ -1,9 +1,25 @@
+# Must be at server scope, not on an inner location: client_max_body_size
+# does not inherit into a nested location that declares its own proxy_pass,
+# and the http-level 1m default would silently apply and reject firmware
+# uploads with 413.
+client_max_body_size 256m;
+
location / {
- proxy_pass http://127.0.0.1:8080;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_redirect off;
+ include /etc/nginx/webui-proxy.conf;
+}
+
+# Keep proxy_request_buffering on (the default). Streaming the body races
+# the Go handler's response close, producing RST instead of FIN at
+# Content-Length and a client-visible 502. nginx spools the body to
+# /var/cache/nginx/client-body during the upload instead.
+location = /firmware/upload {
+ proxy_read_timeout 600s;
+ include /etc/nginx/webui-proxy.conf;
+}
+
+# Liveness probe — nginx-only, no upstream call. Used by the watchdog
+# div in base.html and the reboot-overlay poller.
+location = /device-status {
+ access_log off;
+ return 204;
}
diff --git a/package/webui/webui.mk b/package/webui/webui.mk
index aaf49c2e..303a82d6 100644
--- a/package/webui/webui.mk
+++ b/package/webui/webui.mk
@@ -17,6 +17,8 @@ define WEBUI_INSTALL_EXTRA
$(FINIT_D)/available/webui.conf
$(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/webui.conf \
$(TARGET_DIR)/etc/nginx/app/webui.conf
+ $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/webui-proxy.conf \
+ $(TARGET_DIR)/etc/nginx/webui-proxy.conf
$(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/default.conf \
$(TARGET_DIR)/etc/nginx/available/default.conf
$(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/50x.html \
diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go
index b63ea7ff..f11a4457 100644
--- a/src/webui/internal/handlers/dashboard.go
+++ b/src/webui/internal/handlers/dashboard.go
@@ -271,6 +271,9 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) {
data.OSName = ss.Platform.OSName
data.OSVersion = ss.Platform.OSVersion
data.Machine = ss.Platform.Machine
+ if data.Machine == "arm64" {
+ data.Machine = "aarch64"
+ }
data.Firmware = firmwareVersion(ss.Software)
data.Uptime = computeUptime(ss.Clock.BootDatetime, ss.Clock.CurrentDatetime)
diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go
index a8ad9289..47e8a20e 100644
--- a/src/webui/internal/handlers/system.go
+++ b/src/webui/internal/handlers/system.go
@@ -3,30 +3,72 @@
package handlers
import (
+ "bytes"
+ "context"
+ "encoding/json"
"fmt"
"html/template"
+ "io"
"log"
"net/http"
+ "os"
+ "os/exec"
+ "slices"
+ "strings"
+ "sync"
+ "time"
"github.com/kernelkit/webui/internal/restconf"
)
-// SystemHandler provides reboot, config download, and firmware update actions.
-type SystemHandler struct {
- RC *restconf.Client
- Template *template.Template // firmware page template
+// raucInstallationStatus reads RAUC's Operation/Progress/LastError D-Bus
+// properties directly via the rauc-installation-status helper. Used during
+// installs because the RESTCONF path goes through yanger, which runs
+// `rauc status` and blocks while RAUC is busy.
+func raucInstallationStatus(ctx context.Context) (fwInstallerState, error) {
+ var inst fwInstallerState
+ out, err := exec.CommandContext(ctx, "/usr/bin/rauc-installation-status").Output()
+ if err != nil {
+ return inst, err
+ }
+ err = json.Unmarshal(out, &inst)
+ return inst, err
}
-// DeviceStatus returns 200 if the RESTCONF device is reachable, 502 otherwise.
-// Used by the reboot spinner to detect when the device goes down and comes back.
-func (h *SystemHandler) DeviceStatus(w http.ResponseWriter, r *http.Request) {
- var target struct{}
- err := h.RC.Get(r.Context(), "/data/ietf-system:system-state/platform", &target)
+// SystemHandler provides reboot, config download, and firmware update actions.
+type SystemHandler struct {
+ RC *restconf.Client
+ Template *template.Template // firmware page template
+ SysCtrlTmpl *template.Template // system control page template
+ BackupTmpl *template.Template // backup & restore page template
+
+ // fwSlots caches the last successfully-fetched Software card payload
+ // so /firmware?installing=1 can keep rendering slot details — RESTCONF
+ // /data/ietf-system:system-state blocks on `rauc status` while RAUC
+ // is busy, and the user wants to read (and adjust) boot order even
+ // between install attempts.
+ fwSlots fwSlotSnapshot
+}
+
+// fwSlotSnapshot is a tiny RWMutex-guarded copy of the Firmware page's
+// Software card body. Mirrors the schema.Cache shape (rw lock + payload)
+// from internal/schema/refresh.go.
+type fwSlotSnapshot struct {
+ mu sync.RWMutex
+ machine string
+ bootOrder []string
+ slots []slotEntry
+}
+
+// raucBusy reports whether RAUC has an install in flight. Best-effort:
+// helper-binary failures fall through to "not busy" so the caller's
+// pre-flight check doesn't false-positive on an unrelated D-Bus glitch.
+func (h *SystemHandler) raucBusy(ctx context.Context) bool {
+ inst, err := raucInstallationStatus(ctx)
if err != nil {
- w.WriteHeader(http.StatusBadGateway)
- return
+ return false
}
- w.WriteHeader(http.StatusOK)
+ return !inst.IsIdle()
}
// Reboot triggers a device restart via the ietf-system:system-restart RPC
@@ -43,13 +85,203 @@ func (h *SystemHandler) Reboot(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, rebootSpinnerHTML)
}
-const rebootSpinnerHTML = `
+const rebootSpinnerHTML = `
Rebooting…
Waiting for device to shut down…
`
+type systemControlData struct {
+ PageData
+ CurrentDatetime string // device clock, empty when unavailable
+}
+
+// SystemControl renders the System Control maintenance page.
+func (h *SystemHandler) SystemControl(w http.ResponseWriter, r *http.Request) {
+ data := systemControlData{
+ PageData: newPageData(r, "system-control", "System Control"),
+ }
+
+ var clockResp struct {
+ SystemState struct {
+ Clock struct {
+ CurrentDatetime string `json:"current-datetime"`
+ } `json:"clock"`
+ } `json:"ietf-system:system-state"`
+ }
+ if err := h.RC.Get(r.Context(), "/data/ietf-system:system-state/clock", &clockResp); err == nil {
+ dt := clockResp.SystemState.Clock.CurrentDatetime
+ if len(dt) > 19 {
+ dt = dt[:19]
+ }
+ data.CurrentDatetime = strings.Replace(dt, "T", " ", 1) + " UTC"
+ }
+
+ tmplName := "system-control.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.SysCtrlTmpl.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("system-control template: %v", err)
+ }
+}
+
+// SetDatetime sets the device clock via the ietf-system:set-current-datetime RPC.
+// The form value is a datetime-local string (YYYY-MM-DDTHH:MM) treated as UTC.
+func (h *SystemHandler) SetDatetime(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ raw := r.FormValue("datetime") // YYYY-MM-DDTHH:MM from datetime-local input
+ if raw == "" {
+ http.Error(w, "datetime required", http.StatusBadRequest)
+ return
+ }
+
+ body := map[string]map[string]string{
+ "ietf-system:input": {"current-datetime": raw + ":00+00:00"},
+ }
+ err := h.RC.PostJSON(r.Context(), "/operations/ietf-system:set-current-datetime", body)
+
+ w.Header().Set("Content-Type", "text/html")
+ if err != nil {
+ msg := err.Error()
+ if strings.Contains(msg, "ntp-active") {
+ fmt.Fprint(w, `
NTP is active — disable NTP first under Configure > System.`)
+ } else {
+ fmt.Fprintf(w, `
Failed: %s`, template.HTMLEscapeString(msg))
+ }
+ return
+ }
+ fmt.Fprint(w, `
✓ System time updated`)
+}
+
+// Shutdown triggers a device power-off via the ietf-system:system-shutdown RPC.
+func (h *SystemHandler) Shutdown(w http.ResponseWriter, r *http.Request) {
+ if err := h.RC.Post(r.Context(), "/operations/ietf-system:system-shutdown"); err != nil {
+ log.Printf("shutdown: %v", err)
+ http.Error(w, "shutdown failed", http.StatusBadGateway)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprint(w, shutdownHTML)
+}
+
+// shutdownHTML is the overlay shown after a shutdown RPC succeeds.
+// Uses .reboot-overlay so the 60 s hard-cap redirect fires (session is dead anyway),
+// but omits #reboot-status so the JS does not update the message to "coming back…".
+const shutdownHTML = `
+
+
Shutting down…
+
The device is powering off.
+
`
+
+// FactoryDefault resets the running datastore to factory defaults without rebooting.
+// Uses the infix-factory-default:factory-default RPC.
+func (h *SystemHandler) FactoryDefault(w http.ResponseWriter, r *http.Request) {
+ if err := h.RC.Post(r.Context(), "/operations/infix-factory-default:factory-default"); err != nil {
+ log.Printf("factory-default: %v", err)
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprintf(w, `
Failed: %s`,
+ template.HTMLEscapeString(err.Error()))
+ return
+ }
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprint(w, `
✓ Running config reset to factory defaults`)
+}
+
+// FactoryReset wipes all datastores and non-volatile storage, then reboots.
+// Uses the ietf-factory-default:factory-reset RPC.
+func (h *SystemHandler) FactoryReset(w http.ResponseWriter, r *http.Request) {
+ if err := h.RC.Post(r.Context(), "/operations/ietf-factory-default:factory-reset"); err != nil {
+ log.Printf("factory-reset: %v", err)
+ http.Error(w, "factory reset failed", http.StatusBadGateway)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprint(w, factoryResetSpinnerHTML)
+}
+
+const factoryResetSpinnerHTML = `
+
+
Factory reset in progress…
+
Wiping configuration and rebooting…
+
`
+
+// Backup renders the Backup & Restore maintenance page.
+func (h *SystemHandler) Backup(w http.ResponseWriter, r *http.Request) {
+ data := newPageData(r, "backup", "Backup & Restore")
+ tmplName := "backup.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.BackupTmpl.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("backup template: %v", err)
+ }
+}
+
+// RestoreConfig accepts a multipart-uploaded JSON config file and applies it.
+// target="running" (default): PUT to running so changes take effect immediately;
+// sets the cfg-unsaved cookie so the persistent notification prompts a save.
+// target="startup": PUT to startup only; reboot required to apply.
+func (h *SystemHandler) RestoreConfig(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(10 << 20); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ defer func() {
+ if r.MultipartForm != nil {
+ r.MultipartForm.RemoveAll()
+ }
+ }()
+
+ file, _, err := r.FormFile("config")
+ if err != nil {
+ http.Error(w, "config file required", http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ raw, err := io.ReadAll(file)
+ if err != nil {
+ http.Error(w, "read error", http.StatusInternalServerError)
+ return
+ }
+
+ var check json.RawMessage
+ if err := json.Unmarshal(raw, &check); err != nil {
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprint(w, `
Invalid JSON file.`)
+ return
+ }
+
+ target := "running"
+ if r.FormValue("save-to-startup") == "on" {
+ target = "startup"
+ }
+
+ if err := h.RC.PutDatastore(r.Context(), target, check); err != nil {
+ log.Printf("restore(%s): %v", target, err)
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprintf(w, `
Restore failed: %s`,
+ template.HTMLEscapeString(err.Error()))
+ return
+ }
+
+ if target == "running" {
+ setCfgUnsaved(w)
+ w.Header().Set("HX-Refresh", "true")
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprint(w, `
✓ Startup configuration restored. Reboot to apply.`)
+}
+
// DownloadConfig serves the startup datastore as a JSON file download.
+// Filename includes the device hostname and current UTC date+time.
func (h *SystemHandler) DownloadConfig(w http.ResponseWriter, r *http.Request) {
data, err := h.RC.GetRaw(r.Context(), "/ds/ietf-datastores:startup")
if err != nil {
@@ -58,8 +290,22 @@ func (h *SystemHandler) DownloadConfig(w http.ResponseWriter, r *http.Request) {
return
}
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("Content-Disposition", `attachment; filename="startup-config.json"`)
+ hostname := "device"
+ var sysResp struct {
+ System struct {
+ Hostname string `json:"hostname"`
+ } `json:"ietf-system:system"`
+ }
+ if err := h.RC.Get(r.Context(), "/data/ietf-system:system", &sysResp); err == nil {
+ if hn := sysResp.System.Hostname; hn != "" {
+ hostname = hn
+ }
+ }
+ ts := time.Now().UTC().Format("20060102-1504")
+ filename := fmt.Sprintf("startup-config-%s-%s.cfg", hostname, ts)
+
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
w.Write(data)
}
@@ -67,6 +313,9 @@ func (h *SystemHandler) DownloadConfig(w http.ResponseWriter, r *http.Request) {
type fwSoftwareWrapper struct {
SystemState struct {
+ Platform struct {
+ Machine string `json:"machine"`
+ } `json:"platform"`
Software fwSoftwareState `json:"infix-system:software"`
} `json:"ietf-system:system-state"`
}
@@ -75,6 +324,7 @@ type fwSoftwareState struct {
Compatible string `json:"compatible"`
Variant string `json:"variant"`
Booted string `json:"booted"`
+ BootOrder []string `json:"boot-order"`
Installer fwInstallerState `json:"installer"`
Slots []fwSlot `json:"slot"`
}
@@ -85,17 +335,26 @@ type fwInstallerState struct {
LastError string `json:"last-error"`
}
+// IsIdle reports whether RAUC has no install in flight. The YANG model leaves
+// Operation empty when no install has run yet and "idle" once one has completed.
+func (s fwInstallerState) IsIdle() bool {
+ return s.Operation == "" || s.Operation == "idle"
+}
+
type fwInstallerProgress struct {
Percentage int `json:"percentage"`
Message string `json:"message"`
}
type fwSlot struct {
- Name string `json:"name"`
- BootName string `json:"bootname"`
- Class string `json:"class"`
- State string `json:"state"`
- Bundle fwSlotBundle `json:"bundle"`
+ Name string `json:"name"`
+ BootName string `json:"bootname"`
+ Class string `json:"class"`
+ State string `json:"state"`
+ Bundle fwSlotBundle `json:"bundle"`
+ Installed struct {
+ Datetime string `json:"datetime"`
+ } `json:"installed"`
}
type fwSlotBundle struct {
@@ -107,18 +366,22 @@ type fwSlotBundle struct {
type firmwareData struct {
PageData
- Slots []slotEntry
+ Machine string
+ BootOrder []string
+ Slots []slotEntry
Installer *installerEntry
+ Installing bool // install was triggered this session; keep card visible during RAUC phase gaps
+ AutoReboot bool
Error string
Message string
}
type slotEntry struct {
- Name string
- BootName string
- State string
- Version string
- Booted bool
+ Name string // bootname: primary, secondary, etc.
+ State string
+ Version string
+ InstallDate string
+ Booted bool
}
type installerEntry struct {
@@ -127,38 +390,75 @@ type installerEntry struct {
Message string
LastError string
Active bool
+ Done bool // idle after an install ran (percentage>0 or error set)
+ Success bool // Done with no error
}
// Firmware renders the firmware overview page (GET /firmware).
func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) {
data := firmwareData{
- PageData: newPageData(r, "firmware", "Firmware"),
- Message: r.URL.Query().Get("msg"),
+ PageData: newPageData(r, "firmware", "Firmware"),
+ Message: r.URL.Query().Get("msg"),
+ Installing: r.URL.Query().Get("installing") == "1",
+ AutoReboot: r.URL.Query().Get("auto-reboot") == "1",
}
- var sw fwSoftwareWrapper
- err := h.RC.Get(r.Context(), "/data/ietf-system:system-state", &sw)
- if err != nil {
- log.Printf("restconf firmware: %v", err)
- data.Error = "Could not fetch firmware status"
- } else {
- for _, s := range sw.SystemState.Software.Slots {
- data.Slots = append(data.Slots, slotEntry{
- Name: s.Name,
- BootName: s.BootName,
- State: s.State,
- Version: s.Bundle.Version,
- Booted: s.Name == sw.SystemState.Software.Booted,
- })
+ // When an install is in progress, RESTCONF/yanger blocks on `rauc status`
+ // until RAUC is done. Skip the slow path and just read the installer
+ // state directly so the progress card can render immediately; SSE then
+ // drives the visual update during the install. The Software card body
+ // falls back to the last cached slot snapshot so it doesn't go blank.
+ if data.Installing {
+ if inst, err := raucInstallationStatus(r.Context()); err == nil {
+ data.Installer = newInstallerEntry(inst)
+ } else {
+ log.Printf("firmware page (installing): %v", err)
}
+ h.fwSlots.mu.RLock()
+ data.Machine = h.fwSlots.machine
+ data.BootOrder = slices.Clone(h.fwSlots.bootOrder)
+ data.Slots = slices.Clone(h.fwSlots.slots)
+ h.fwSlots.mu.RUnlock()
+ } else {
+ var sw fwSoftwareWrapper
+ err := h.RC.Get(r.Context(), "/data/ietf-system:system-state", &sw)
+ if err != nil {
+ log.Printf("restconf firmware: %v", err)
+ data.Error = "Could not fetch firmware status"
+ } else {
+ data.Machine = sw.SystemState.Platform.Machine
+ if data.Machine == "arm64" {
+ data.Machine = "aarch64"
+ }
+ data.BootOrder = sw.SystemState.Software.BootOrder
+ for _, s := range sw.SystemState.Software.Slots {
+ if s.Class != "rootfs" {
+ continue
+ }
+ name := s.BootName
+ if name == "" {
+ name = s.Name
+ }
+ date := s.Installed.Datetime
+ if len(date) > 19 {
+ date = date[:19]
+ }
+ data.Slots = append(data.Slots, slotEntry{
+ Name: name,
+ State: s.State,
+ Version: s.Bundle.Version,
+ InstallDate: date,
+ Booted: s.BootName == sw.SystemState.Software.Booted,
+ })
+ }
- inst := sw.SystemState.Software.Installer
- data.Installer = &installerEntry{
- Operation: inst.Operation,
- Percentage: inst.Progress.Percentage,
- Message: inst.Progress.Message,
- LastError: inst.LastError,
- Active: inst.Operation != "" && inst.Operation != "idle",
+ data.Installer = newInstallerEntry(sw.SystemState.Software.Installer)
+
+ h.fwSlots.mu.Lock()
+ h.fwSlots.machine = data.Machine
+ h.fwSlots.bootOrder = slices.Clone(data.BootOrder)
+ h.fwSlots.slots = slices.Clone(data.Slots)
+ h.fwSlots.mu.Unlock()
}
}
@@ -172,6 +472,130 @@ func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) {
}
}
+// SetBootOrder calls the infix-system:set-boot-order RPC with the ordered
+// boot-order form values submitted by the boot order card.
+// On success it refreshes the page so the Software card badges update.
+func (h *SystemHandler) SetBootOrder(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ order := r.Form["boot-order"]
+ if len(order) == 0 || len(order) > 3 {
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprint(w, `
Invalid boot order.`)
+ return
+ }
+ body := map[string]any{
+ "infix-system:input": map[string]any{
+ "boot-order": order,
+ },
+ }
+ if err := h.RC.PostJSON(r.Context(), "/operations/infix-system:set-boot-order", body); err != nil {
+ log.Printf("set-boot-order: %v", err)
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprintf(w, `
Failed: %s`,
+ template.HTMLEscapeString(err.Error()))
+ return
+ }
+ w.Header().Set("HX-Refresh", "true")
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// FirmwareUpload accepts a .pkg file upload, saves it to a temp file, and
+// kicks off the install-bundle RPC asynchronously so the response (a
+// plain-text redirect target) reaches the browser before RAUC starts
+// writing slots. Upload size is capped at the nginx layer.
+func (h *SystemHandler) FirmwareUpload(w http.ResponseWriter, r *http.Request) {
+ if h.raucBusy(r.Context()) {
+ http.Error(w, "firmware install already in progress", http.StatusConflict)
+ return
+ }
+
+ // 1 MiB in-RAM threshold; larger parts spill to $TMPDIR (/var/tmp on
+ // the target, eMMC-backed) instead of the RAM-backed /tmp.
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ defer func() {
+ if r.MultipartForm != nil {
+ r.MultipartForm.RemoveAll()
+ }
+ }()
+
+ file, _, err := r.FormFile("pkg")
+ if err != nil {
+ http.Error(w, "pkg file required", http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ tmp, err := os.CreateTemp("", "webui-fw-*.pkg")
+ if err != nil {
+ log.Printf("firmware upload: create temp: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ tmpPath := tmp.Name()
+
+ if _, err := io.Copy(tmp, file); err != nil {
+ tmp.Close()
+ os.Remove(tmpPath)
+ log.Printf("firmware upload: write: %v", err)
+ http.Error(w, "failed to save firmware", http.StatusInternalServerError)
+ return
+ }
+ tmp.Close()
+
+ body := map[string]map[string]string{
+ "infix-system:input": {"url": tmpPath},
+ }
+ creds := restconf.CredentialsFromContext(r.Context())
+ go h.runInstall(creds, body, tmpPath)
+
+ target := "/firmware?installing=1"
+ if r.FormValue("auto-reboot") == "1" {
+ target += "&auto-reboot=1"
+ }
+ w.Header().Set("Content-Type", "text/plain")
+ fmt.Fprint(w, target)
+}
+
+// runInstall fires the install-bundle RPC and then waits for RAUC to finish
+// before deleting the uploaded .pkg. The 30-minute outer timeout is the
+// safety net for a stuck RAUC.
+func (h *SystemHandler) runInstall(creds restconf.Credentials, body any, tmpPath string) {
+ ctx, cancel := context.WithTimeout(
+ restconf.ContextWithCredentials(context.Background(), creds),
+ 30*time.Minute,
+ )
+ defer cancel()
+ defer os.Remove(tmpPath)
+
+ if err := h.RC.PostJSON(ctx, "/operations/infix-system:install-bundle", body); err != nil {
+ log.Printf("firmware upload: install-bundle: %v", err)
+ return
+ }
+
+ ticker := time.NewTicker(2 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ inst, err := raucInstallationStatus(ctx)
+ if err != nil {
+ continue
+ }
+ if inst.IsIdle() {
+ return
+ }
+ }
+ }
+}
+
// FirmwareInstall triggers a firmware install via the install-bundle RPC (POST /firmware/install).
func (h *SystemHandler) FirmwareInstall(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
@@ -199,6 +623,119 @@ func (h *SystemHandler) FirmwareInstall(w http.ResponseWriter, r *http.Request)
return
}
- w.Header().Set("HX-Redirect", "/firmware?msg=Install+started")
+ target := "/firmware?installing=1"
+ if r.FormValue("auto-reboot") == "1" {
+ target += "&auto-reboot=1"
+ }
+ w.Header().Set("HX-Redirect", target)
w.WriteHeader(http.StatusNoContent)
}
+
+// FirmwareProgress streams installer status as SSE so the Go server does the
+// polling and the browser just receives rendered HTML fragments.
+// GET /firmware/progress
+func (h *SystemHandler) FirmwareProgress(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming not supported", http.StatusInternalServerError)
+ return
+ }
+
+ autoReboot := r.URL.Query().Get("auto-reboot") == "1"
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("X-Accel-Buffering", "no")
+ w.WriteHeader(http.StatusOK)
+ flusher.Flush()
+
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+
+ var lastKey string // change-detection: suppress redundant SSE frames
+
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+ case <-ticker.C:
+ data := h.installerSnapshot(r, autoReboot)
+
+ // Build a cheap key for change detection; skip frames with identical state.
+ var key string
+ if data.Installer != nil {
+ key = fmt.Sprintf("%s|%d|%s|%s", data.Installer.Operation, data.Installer.Percentage, data.Installer.Message, data.Installer.LastError)
+ }
+ if key == lastKey && key != "" {
+ continue
+ }
+ lastKey = key
+
+ var buf bytes.Buffer
+ if err := h.Template.ExecuteTemplate(&buf, "fw-progress-body", data); err != nil {
+ log.Printf("firmware progress template: %v", err)
+ continue
+ }
+
+ // SSE data must not contain raw newlines; collapse to spaces.
+ line := strings.ReplaceAll(buf.String(), "\n", " ")
+
+ eventName := "progress"
+ if data.Installer != nil && data.Installer.Done {
+ if autoReboot && data.Installer.Success {
+ eventName = "reboot"
+ } else {
+ eventName = "done"
+ }
+ }
+
+ fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventName, line)
+ flusher.Flush()
+
+ if data.Installer != nil && data.Installer.Done {
+ return
+ }
+ }
+ }
+}
+
+// installerSnapshot reads RAUC's installer state via rauc-installation-status
+// (direct D-Bus property read) and builds the template data for the
+// fw-progress-body fragment. The RESTCONF path is avoided because yanger runs
+// `rauc status`, which blocks while an install is in progress.
+func (h *SystemHandler) installerSnapshot(r *http.Request, autoReboot bool) firmwareProgressData {
+ data := firmwareProgressData{
+ AutoReboot: autoReboot,
+ }
+
+ inst, err := raucInstallationStatus(r.Context())
+ if err != nil {
+ // Leave Installer nil so the template renders an indeterminate
+ // "Installing…" state on transient failures.
+ log.Printf("firmware progress poll: %v", err)
+ return data
+ }
+ data.Installer = newInstallerEntry(inst)
+ return data
+}
+
+// newInstallerEntry converts a raw YANG installer state to the template-facing struct.
+func newInstallerEntry(inst fwInstallerState) *installerEntry {
+ idle := inst.IsIdle()
+ done := idle && (inst.Progress.Percentage > 0 || inst.LastError != "")
+ return &installerEntry{
+ Operation: inst.Operation,
+ Percentage: inst.Progress.Percentage,
+ Message: inst.Progress.Message,
+ LastError: inst.LastError,
+ Active: !idle,
+ Done: done,
+ Success: done && inst.LastError == "",
+ }
+}
+
+// firmwareProgressData is the template data for the fw-progress-body fragment.
+type firmwareProgressData struct {
+ AutoReboot bool
+ Installer *installerEntry
+}
diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go
index 63087d39..e012bcf8 100644
--- a/src/webui/internal/server/server.go
+++ b/src/webui/internal/server/server.go
@@ -142,6 +142,7 @@ func New(
mux.HandleFunc("GET /firewall", fw.Overview)
mux.HandleFunc("GET /keystore", ks.Overview)
mux.HandleFunc("GET /firmware", sys.Firmware)
+ mux.HandleFunc("GET /firmware/progress", sys.FirmwareProgress)
mux.HandleFunc("POST /firmware/install", sys.FirmwareInstall)
mux.HandleFunc("POST /reboot", sys.Reboot)
mux.HandleFunc("GET /device-status", sys.DeviceStatus)
diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css
index 3d388e25..15909695 100644
--- a/src/webui/static/css/style.css
+++ b/src/webui/static/css/style.css
@@ -85,6 +85,7 @@
/* Manual Dark Mode Toggle */
.dark {
+ color-scheme: dark;
--bg: var(--slate-900);
--surface: var(--slate-800);
--fg: var(--slate-100);
@@ -102,6 +103,7 @@
/* Force Light Mode (override system dark preference) */
.light {
+ color-scheme: light;
--bg: var(--slate-50);
--surface: #ffffff;
--fg: var(--slate-900);
@@ -978,8 +980,165 @@ details[open].nav-group-top > summary.nav-group-summary-top::after {
}
/* Firmware & Reboot */
-.firmware-form { display: flex; gap: 1rem; align-items: flex-end; }
-.firmware-form .form-group { flex: 1; margin-bottom: 0; }
+.fw-install-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
+ gap: 1.5rem;
+ margin-top: 1.5rem;
+}
+.fw-install-grid > .info-card { margin: 0; }
+
+.fw-card-muted { opacity: 0.6; pointer-events: none; }
+
+.fw-help-text {
+ font-size: 0.875rem;
+ color: var(--fg-muted);
+ margin-bottom: 1rem;
+ line-height: 1.6;
+}
+.fw-help-text a { color: var(--primary); }
+.fw-help-text code, .fw-hint-body code { font-family: var(--font-mono); font-size: 0.8em; }
+
+.fw-hint {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ margin-bottom: 1.25rem;
+ font-size: 0.875rem;
+}
+.fw-hint summary {
+ cursor: pointer;
+ padding: 0.55rem 0.8rem;
+ color: var(--fg-muted);
+ user-select: none;
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+.fw-hint summary::-webkit-details-marker { display: none; }
+.fw-hint summary::before { content: '›'; display: inline-block; transition: transform 0.15s; }
+details.fw-hint[open] summary::before { transform: rotate(90deg); }
+.fw-hint-body {
+ padding: 0.75rem 0.8rem;
+ border-top: 1px solid var(--border);
+}
+.fw-hint-body p { margin: 0 0 0.5rem; color: var(--fg-muted); font-size: 0.85rem; }
+.fw-hint-body p:last-child { margin-bottom: 0; }
+.fw-hint-code {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 0.6rem 0.75rem;
+ white-space: pre;
+ overflow-x: auto;
+ color: var(--fg);
+ margin-bottom: 0.5rem;
+}
+
+.fw-boot-order-row {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.55rem 1.25rem;
+ border-bottom: 1px solid var(--border);
+ font-size: 0.8rem;
+}
+.fw-boot-order-label {
+ color: var(--fg-muted);
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ font-weight: 600;
+ margin-right: 0.2rem;
+}
+
+.fw-slot-list { display: flex; flex-direction: column; }
+.fw-slot-item {
+ padding: 0.75rem 1.25rem;
+ border-bottom: 1px solid var(--border);
+}
+.fw-slot-item:last-child { border-bottom: none; }
+.fw-slot-primary {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.2rem;
+}
+.fw-slot-name { font-weight: 600; font-size: 0.9rem; }
+.fw-slot-version {
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ color: var(--fg-muted);
+}
+.fw-slot-date {
+ font-size: 0.75rem;
+ color: var(--fg-muted);
+ opacity: 0.75;
+ margin-top: 0.1rem;
+}
+
+.fw-upload-placeholder {
+ border: 2px dashed var(--border);
+ border-radius: var(--radius);
+ padding: 2rem 1rem;
+ text-align: center;
+ color: var(--fg-muted);
+ margin-bottom: 1rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.6rem;
+ font-size: 0.875rem;
+}
+
+.firmware-form .form-group { margin-bottom: 0.75rem; }
+.firmware-form .fw-checkbox-row { margin-bottom: 0.75rem; }
+
+.fw-checkbox-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.875rem;
+ color: var(--fg-muted);
+ cursor: pointer;
+ user-select: none;
+}
+.fw-checkbox-row input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; }
+
+.fw-result {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 0.9rem;
+ font-weight: 500;
+}
+.fw-result svg { flex-shrink: 0; }
+.fw-result-ok { color: var(--success); }
+.fw-result-err { color: var(--danger); }
+.fw-result-body { display: flex; flex-direction: column; }
+.fw-result-actions { margin-top: 1rem; }
+
+/* Compact pill badges used in the firmware slot list and boot-order row. */
+.fw-install-grid .badge-neutral {
+ background: var(--slate-200);
+ font-size: 0.65rem;
+ font-weight: 600;
+ padding: 0.15em 0.5em;
+ border-radius: 999px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ vertical-align: middle;
+}
+@media (prefers-color-scheme: dark) {
+ .fw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); }
+}
+.dark .fw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); }
+
+.progress-bar-wrap--flush { margin-top: 0; }
.progress-bar-wrap {
background: var(--slate-200);
diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js
index 845397da..ee8cf7e1 100644
--- a/src/webui/static/js/app.js
+++ b/src/webui/static/js/app.js
@@ -58,63 +58,28 @@
}
overlay.dataset.init = 'true';
- var timeout = parseInt(overlay.getAttribute('data-timeout'), 10);
- if (isNaN(timeout) || timeout <= 0) {
- timeout = 120000;
- }
- var interval = parseInt(overlay.getAttribute('data-interval'), 10);
- if (isNaN(interval) || interval <= 0) {
- interval = 2000;
- }
- var start = Date.now();
var status = overlay.querySelector('#reboot-status');
- var returnTo = window.location.pathname + window.location.search;
- function waitDown() {
- if (Date.now() - start > timeout) {
- if (status) {
- status.textContent = 'Timeout - device did not shut down within 2 minutes.';
- status.classList.add('is-error');
- }
- return;
- }
- fetch('/device-status').then(function (r) {
- if (r.ok) {
- setTimeout(waitDown, interval);
- } else {
- if (status) {
- status.textContent = 'Device is down, waiting for it to come back...';
+ // Hard cap: redirect home after 60 s regardless of poll outcome.
+ setTimeout(function () { window.location.replace('/'); }, 60000);
+
+ // Wait 4 s for the device to shut down, then start polling for it to come back.
+ // This avoids the race where a fast reboot never shows as "down" on the 1 s poll.
+ setTimeout(function () {
+ if (status) status.textContent = 'Waiting for device to come back\u2026';
+ function poll() {
+ fetch('/device-status').then(function (r) {
+ if (r.ok) {
+ window.location.replace('/');
+ } else {
+ setTimeout(poll, 2000);
}
- setTimeout(waitUp, interval);
- }
- }).catch(function () {
- if (status) {
- status.textContent = 'Device is down, waiting for it to come back...';
- }
- setTimeout(waitUp, interval);
- });
- }
-
- function waitUp() {
- if (Date.now() - start > timeout) {
- if (status) {
- status.textContent = 'Timeout - device did not respond within 2 minutes.';
- status.classList.add('is-error');
- }
- return;
+ }).catch(function () {
+ setTimeout(poll, 2000);
+ });
}
- fetch('/device-status').then(function (r) {
- if (r.ok) {
- window.location = returnTo || '/';
- } else {
- setTimeout(waitUp, interval);
- }
- }).catch(function () {
- setTimeout(waitUp, interval);
- });
- }
-
- setTimeout(waitDown, interval);
+ poll();
+ }, 8000);
}
function initDynamicUI(root) {
@@ -122,15 +87,73 @@
startRebootOverlay(root);
}
- document.addEventListener('DOMContentLoaded', function () {
+ // SSE-driven firmware progress card.
+ // The Go server polls RESTCONF and streams rendered HTML fragments; we just
+ // swap them into the card and let the server close the stream when done.
+ var fwEventSource = null;
+
+ function initFirmwareProgress(root) {
+ var scope = root || document;
+ var card = scope.querySelector('#fw-progress-card[data-sse-src]');
+ if (!card || card.dataset.sseInit) return;
+ card.dataset.sseInit = 'true';
+
+ var src = card.getAttribute('data-sse-src');
+ if (fwEventSource) { fwEventSource.close(); }
+ fwEventSource = new EventSource(src);
+
+ function swap(html) {
+ card.innerHTML = html;
+ initProgressBars(card);
+ if (window.htmx) htmx.process(card);
+ }
+
+ fwEventSource.addEventListener('progress', function(e) { swap(e.data); });
+
+ fwEventSource.addEventListener('done', function(e) {
+ swap(e.data);
+ fwEventSource.close();
+ fwEventSource = null;
+ });
+
+ fwEventSource.addEventListener('reboot', function(e) {
+ swap(e.data);
+ fwEventSource.close();
+ fwEventSource = null;
+ // Auto-reboot: POST /reboot and show the reboot overlay.
+ fetch('/reboot', { method: 'POST', headers: { 'X-CSRF-Token': getCSRFToken() } })
+ .then(function(r) { return r.text(); })
+ .then(function(html) {
+ var content = document.getElementById('content');
+ if (content) {
+ content.innerHTML = html;
+ initDynamicUI(content);
+ }
+ });
+ });
+
+ fwEventSource.onerror = function() {
+ fwEventSource.close();
+ fwEventSource = null;
+ };
+ }
+
+ document.addEventListener('DOMContentLoaded', function() {
initCSRF();
initDeviceStatusBanner();
initDynamicUI(document);
+ initFirmwareProgress(document);
});
if (window.htmx && document.body) {
document.body.addEventListener('htmx:afterSwap', function (evt) {
+ // Close any open SSE stream if the firmware progress card is no longer present.
+ if (fwEventSource && !document.getElementById('fw-progress-card')) {
+ fwEventSource.close();
+ fwEventSource = null;
+ }
initDynamicUI(evt.target);
+ initFirmwareProgress(evt.target);
});
}
})();
@@ -156,9 +179,11 @@
if (activeTopGroup) {
document.querySelectorAll('details.nav-group-top').forEach(function(d) {
if (d === activeTopGroup) {
- d.setAttribute('open', '');
+ // Don't auto-open Configure: its toggle handler fires enterConfigure(),
+ // which must only happen on explicit user interaction, not on URL sync.
+ if (d.id !== 'nav-configure' && !d.open) d.open = true;
} else {
- d.removeAttribute('open');
+ if (d.open) d.open = false;
}
});
}
@@ -292,21 +317,45 @@
})();
// Accordion nav group persistence
-// Top-level sections (Status / Configure / Maintenance) are mutually exclusive.
-// State is persisted in localStorage under 'nav-top:Name' keys.
+// Status and Maintenance persist open/closed state in localStorage.
+// Configure is excluded from persistence: its state is controlled by the
+// server template. Configure's Enter POST (running→candidate) is fired
+// from JS the first time the accordion opens per page lifecycle.
(function() {
document.addEventListener('DOMContentLoaded', function() {
var groups = document.querySelectorAll('details.nav-group-top');
+ var configureEntered = false;
- // Restore saved state (HTML default: Status open, others closed)
+ // Fire Configure Enter POST once per page lifecycle (initialises the
+ // candidate datastore). Called when the accordion is opened or when the
+ // page loads with the accordion already open (e.g. on a /configure/ page).
+ function enterConfigure() {
+ if (configureEntered) return;
+ configureEntered = true;
+ htmx.ajax('POST', '/configure/enter', {swap: 'none', target: document.body});
+ }
+
+ // If the Configure accordion is already open on page load, fire Enter now.
+ var configureDetails = document.getElementById('nav-configure');
+ if (configureDetails && configureDetails.open) {
+ enterConfigure();
+ }
+
+ // Restore saved state — skip Configure so it never auto-reopens on page load.
+ // When closing an accordion, don't close it if the active page is inside it
+ // (updateActiveNav has already run at this point and set .nav-link.active).
+ // Mark elements being programmatically restored so the toggle handler below
+ // skips auto-navigation for these synthetic toggles (toggle fires async).
groups.forEach(function(d) {
+ if (d.id === 'nav-configure') return;
var label = d.querySelector(':scope > summary');
if (!label) return;
var key = 'nav-top:' + label.textContent.trim();
var saved = localStorage.getItem(key);
if (saved === 'open') {
+ d.dataset.navRestoring = 'true';
d.setAttribute('open', '');
- } else if (saved === 'closed') {
+ } else if (saved === 'closed' && !d.querySelector('.nav-link.active')) {
d.removeAttribute('open');
}
});
@@ -317,14 +366,26 @@
if (!label) return;
var key = 'nav-top:' + label.textContent.trim();
d.addEventListener('toggle', function() {
- localStorage.setItem(key, d.open ? 'open' : 'closed');
+ // Skip synthetic toggles fired during page-load state restoration.
+ if (d.dataset.navRestoring) {
+ delete d.dataset.navRestoring;
+ return;
+ }
+ if (d.id !== 'nav-configure') {
+ localStorage.setItem(key, d.open ? 'open' : 'closed');
+ }
if (d.open) {
+ if (d.id === 'nav-configure') {
+ enterConfigure();
+ }
groups.forEach(function(other) {
if (other !== d && other.open) {
other.removeAttribute('open');
- var otherLabel = other.querySelector(':scope > summary');
- if (otherLabel) {
- localStorage.setItem('nav-top:' + otherLabel.textContent.trim(), 'closed');
+ if (other.id !== 'nav-configure') {
+ var otherLabel = other.querySelector(':scope > summary');
+ if (otherLabel) {
+ localStorage.setItem('nav-top:' + otherLabel.textContent.trim(), 'closed');
+ }
}
}
});
@@ -371,6 +432,31 @@
});
})();
+// Card collapsible — "Show more / Show less" toggle injected when content overflows
+(function() {
+ function initCollapsibles() {
+ document.querySelectorAll('.card-collapsible:not([data-ci])').forEach(function(card) {
+ card.setAttribute('data-ci', '1');
+ var body = card.querySelector('.card-collapsible-body');
+ if (!body || body.scrollHeight <= body.clientHeight + 4) return;
+
+ var btn = document.createElement('button');
+ btn.className = 'card-expand-btn';
+ btn.type = 'button';
+ btn.textContent = 'Show more \u25be';
+ btn.style.display = 'block';
+ card.appendChild(btn);
+
+ btn.addEventListener('click', function() {
+ var expanded = card.classList.toggle('is-expanded');
+ btn.textContent = expanded ? 'Show less \u25b4' : 'Show more \u25be';
+ });
+ });
+ }
+ document.addEventListener('DOMContentLoaded', initCollapsibles);
+ document.addEventListener('htmx:afterSettle', initCollapsibles);
+})();
+
// Sidebar toggle (mobile)
(function() {
var MOBILE_BP = 1024;
@@ -465,3 +551,183 @@
});
});
})();
+
+// ─── Confirm dialog ────────────────────────────────────────────────────────
+// openModal(message, onConfirm) shows the shared