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 and calls onConfirm +// if the user clicks Confirm, or does nothing on Cancel. +function openModal(message, onConfirm) { + var dlg = document.getElementById('confirm-dialog'); + var msg = document.getElementById('dialog-message'); + var ok = document.getElementById('dialog-confirm'); + var no = document.getElementById('dialog-cancel'); + if (!dlg) { onConfirm(); return; } // fallback if dialog missing + msg.textContent = message; + + function cleanup() { + ok.removeEventListener('click', handleOK); + no.removeEventListener('click', handleNo); + dlg.close(); + } + function handleOK() { cleanup(); onConfirm(); } + function handleNo() { cleanup(); } + + ok.addEventListener('click', handleOK); + no.addEventListener('click', handleNo); + dlg.showModal(); +} + +// ─── data-show / data-hide: toggle element visibility ────────────────────── +(function() { + document.addEventListener('click', function(e) { + var show = e.target.closest('[data-show]'); + if (show) { + var el = document.getElementById(show.getAttribute('data-show')); + if (el) { el.hidden = false; el.querySelector('input,select,textarea') && el.querySelector('input,select,textarea').focus(); } + } + var hide = e.target.closest('[data-hide]'); + if (hide) { + var el = document.getElementById(hide.getAttribute('data-hide')); + if (el) el.hidden = true; + } + }); +})(); + +// ─── Configure: dynamic list row add/delete ──────────────────────────────── +// Handles .btn-add-row (data-table, data-template) and .cfg-delete-row buttons. +// Templates are keyed by data-template attribute value. +(function () { + var rowTemplates = { + 'ntp': function(i) { + return '' + + '' + + '' + + '' + + '' + + '' + deleteBtn() + '' + + ''; + }, + 'dns-search': function(i) { + return '' + + '' + + '' + deleteBtn() + '' + + ''; + }, + 'dns-server': function(i) { + return '' + + '' + + '' + + '' + + '' + deleteBtn() + '' + + ''; + } + }; + + function deleteBtn() { + return ''; + } + + // Re-index all inputs in a tbody so names stay sequential after add/delete. + function renumber(tbody) { + tbody.querySelectorAll('tr').forEach(function(row, i) { + row.querySelectorAll('input').forEach(function(inp) { + inp.name = inp.name.replace(/_\d+$/, '_' + i); + }); + }); + } + + document.addEventListener('click', function(e) { + var addBtn = e.target.closest('.btn-add-row'); + if (addBtn) { + var tbodyId = addBtn.getAttribute('data-table'); + var tmplKey = addBtn.getAttribute('data-template'); + var tbody = document.getElementById(tbodyId); + if (!tbody || !rowTemplates[tmplKey]) { return; } + var idx = tbody.querySelectorAll('tr').length; + tbody.insertAdjacentHTML('beforeend', rowTemplates[tmplKey](idx)); + var newInput = tbody.querySelector('tr:last-child input'); + if (newInput) { newInput.focus(); } + return; + } + + var delBtn = e.target.closest('.cfg-delete-row'); + if (delBtn) { + var row = delBtn.closest('tr'); + var tbody = row && row.closest('tbody'); + if (row) { row.remove(); } + if (tbody) { renumber(tbody); } + } + }); +})(); + +// ─── Configure toolbar ───────────────────────────────────────────────────── +// Intercept HTMX's confirm event for Apply/Abort buttons and show the custom +// modal instead of the browser native confirm. The server responds with +// HX-Redirect: / so HTMX performs a full-page navigation (window.location.href), +// which re-renders the sidebar and clears the Configure accordion. +(function () { + document.addEventListener('htmx:confirm', function(e) { + var btn = e.detail.elt; + if (!btn || (!btn.classList.contains('cfg-apply-btn') && !btn.classList.contains('cfg-abort-btn'))) return; + e.preventDefault(); + openModal(e.detail.question, function() { + e.detail.issueRequest(true); + }); + }); + + // Show "Saved ✓" feedback when a card Save succeeds. + (function() { + var LS_KEY = 'fw-url-history'; + var MAX_HIST = 10; + + function loadHistory() { + try { return JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } + catch (e) { return []; } + } + + function saveURL(url) { + var hist = loadHistory().filter(function(u) { return u !== url; }); + hist.unshift(url); + if (hist.length > MAX_HIST) hist = hist.slice(0, MAX_HIST); + localStorage.setItem(LS_KEY, JSON.stringify(hist)); + } + + function populateDatalist() { + var dl = document.getElementById('fw-url-history'); + if (!dl) return; + dl.innerHTML = loadHistory().map(function(u) { + return '