webui: redesign firmware page, use slot bootnames

Replace the RAUC-centric firmware page with a cleaner layout:

- Show software slots (primary/secondary) using their bootnames rather
  than internal RAUC slot names; filter to rootfs-class slots only
- Add boot order display (mirrors `show software` in the CLI)
- Show installed timestamp per slot (truncated to second precision)
- Fix Booted flag comparison: compare against bootname not slot name
- Lay out all three cards (Software, Install from URL, Upload & Install)
  in a responsive grid instead of full-width stacked sections

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-14 22:07:26 +02:00
parent f2f52f8518
commit 12482a5ab5
9 changed files with 1248 additions and 177 deletions
+11
View File
@@ -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;
+23 -7
View File
@@ -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;
}
+2
View File
@@ -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 \
+3
View File
@@ -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)
+587 -50
View File
@@ -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 = `<div class="reboot-overlay" data-timeout="120000" data-interval="2000">
const rebootSpinnerHTML = `<div class="reboot-overlay">
<div class="reboot-spinner"></div>
<p class="reboot-message">Rebooting&hellip;</p>
<p class="reboot-status" id="reboot-status">Waiting for device to shut down&hellip;</p>
</div>`
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, `<span class="sc-fd-err">NTP is active &mdash; disable NTP first under Configure &gt; System.</span>`)
} else {
fmt.Fprintf(w, `<span class="sc-fd-err">Failed: %s</span>`, template.HTMLEscapeString(msg))
}
return
}
fmt.Fprint(w, `<span class="sc-fd-ok">&#10003; System time updated</span>`)
}
// 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 = `<div class="reboot-overlay">
<div class="reboot-spinner"></div>
<p class="reboot-message">Shutting down&hellip;</p>
<p class="reboot-status">The device is powering off.</p>
</div>`
// 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, `<span class="sc-fd-err">Failed: %s</span>`,
template.HTMLEscapeString(err.Error()))
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<span class="sc-fd-ok">&#10003; Running config reset to factory defaults</span>`)
}
// 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 = `<div class="reboot-overlay">
<div class="reboot-spinner"></div>
<p class="reboot-message">Factory reset in progress&hellip;</p>
<p class="reboot-status" id="reboot-status">Wiping configuration and rebooting&hellip;</p>
</div>`
// 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, `<span class="sc-fd-err">Invalid JSON file.</span>`)
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, `<span class="sc-fd-err">Restore failed: %s</span>`,
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, `<span class="sc-fd-ok">&#10003; Startup configuration restored. Reboot to apply.</span>`)
}
// 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, `<span class="sc-fd-err">Invalid boot order.</span>`)
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, `<span class="sc-fd-err">Failed: %s</span>`,
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
}
+1
View File
@@ -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)
+161 -2
View File
@@ -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);
+330 -64
View File
@@ -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 <dialog> 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 '<tr>' +
'<td><input class="cfg-input" type="text" name="ntp_name_' + i + '" required></td>' +
'<td><input class="cfg-input" type="text" name="ntp_addr_' + i + '"></td>' +
'<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"' +
' name="ntp_port_' + i + '" placeholder="123"></td>' +
'<td style="text-align:center"><input type="checkbox" name="ntp_prefer_' + i + '"></td>' +
'<td>' + deleteBtn() + '</td>' +
'</tr>';
},
'dns-search': function(i) {
return '<tr>' +
'<td><input class="cfg-input" type="text" name="dns_search_' + i + '"></td>' +
'<td>' + deleteBtn() + '</td>' +
'</tr>';
},
'dns-server': function(i) {
return '<tr>' +
'<td><input class="cfg-input" type="text" name="dns_name_' + i + '" required></td>' +
'<td><input class="cfg-input" type="text" name="dns_addr_' + i + '"></td>' +
'<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"' +
' name="dns_port_' + i + '" placeholder="53"></td>' +
'<td>' + deleteBtn() + '</td>' +
'</tr>';
}
};
function deleteBtn() {
return '<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>' +
'</svg></button>';
}
// 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 '<option value="' + u.replace(/&/g, '&amp;').replace(/"/g, '&quot;') + '">';
}).join('');
}
document.addEventListener('DOMContentLoaded', populateDatalist);
document.addEventListener('htmx:afterSettle', populateDatalist);
document.addEventListener('submit', function(e) {
var form = e.target.closest('.firmware-form');
if (!form) return;
var input = form.querySelector('input[name="url"]');
if (input && input.value) saveURL(input.value);
});
})();
document.addEventListener('cfgSaved', function(e) {
var msg = e.detail && e.detail.value ? e.detail.value : 'Saved';
// Find the status span closest to the form that triggered the event.
var form = e.target && e.target.closest('form');
var span = form ? form.querySelector('.cfg-save-status') : null;
if (span) {
span.textContent = '✓ ' + msg;
span.classList.add('saved');
setTimeout(function() {
span.textContent = '';
span.classList.remove('saved');
}, 3000);
}
});
})();
+130 -54
View File
@@ -11,59 +11,135 @@
<div class="alert alert-info">{{.Message}}</div>
{{end}}
{{if .Installer}}{{if .Installer.Active}}
<section class="info-card" hx-get="/firmware" hx-trigger="every 3s" hx-target="#content">
<div class="card-header">Install in Progress</div>
<div class="progress-bar-wrap">
<div class="progress-bar" data-progress="{{.Installer.Percentage}}"></div>
</div>
<p class="progress-text">{{.Installer.Percentage}}% — {{.Installer.Message}}</p>
</section>
{{end}}{{end}}
{{if .Installer}}{{if .Installer.LastError}}
<div class="alert alert-error">Last error: {{.Installer.LastError}}</div>
{{end}}{{end}}
<section class="info-card">
<div class="card-header">Firmware Slots</div>
{{if .Slots}}
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Slot</th>
<th>State</th>
<th>Version</th>
</tr>
</thead>
<tbody>
{{range .Slots}}
<tr>
<td>{{.Name}}{{if .BootName}} ({{.BootName}}){{end}}{{if .Booted}} <span class="badge badge-accept">booted</span>{{end}}</td>
<td>{{.State}}</td>
<td>{{if .Version}}{{.Version}}{{else}}-{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty-message">No firmware slot information available.</p>
{{end}}
</section>
<section class="info-card">
<div class="card-header">Install Firmware</div>
<form hx-post="/firmware/install" hx-swap="none" class="firmware-form">
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
<div class="form-group">
<label for="fw-url">Bundle URL</label>
<input type="url" id="fw-url" name="url" placeholder="https://example.com/firmware.raucb" required>
</div>
<button type="submit" class="btn btn-primary"
hx-confirm="Install firmware from this URL?"
{{if .Installer}}{{if .Installer.Active}}disabled{{end}}{{end}}>Install</button>
</form>
{{if .Installing}}
{{$sseURL := "/firmware/progress"}}{{if .AutoReboot}}{{$sseURL = "/firmware/progress?auto-reboot=1"}}{{end}}
<section id="fw-progress-card" class="info-card" data-sse-src="{{$sseURL}}">
{{template "fw-progress-body" .}}
</section>
{{end}}
<div class="fw-install-grid">
<section class="info-card">
<div class="card-header">Software</div>
{{if .BootOrder}}
<div class="fw-boot-order-row">
<span class="fw-boot-order-label">Boot order</span>
{{range .BootOrder}}<span class="badge badge-neutral">{{.}}</span>{{end}}
</div>
{{end}}
{{if .Slots}}
<div class="fw-slot-list">
{{range .Slots}}
<div class="fw-slot-item">
<div class="fw-slot-primary">
<span class="fw-slot-name">{{.Name}}</span>
{{if .Booted}}<span class="badge badge-accept">booted</span>
{{else}}<span class="badge badge-neutral">{{.State}}</span>{{end}}
</div>
<div class="fw-slot-version">{{if .Version}}{{.Version}}{{else}}&mdash;{{end}}</div>
{{if .InstallDate}}<div class="fw-slot-date">{{.InstallDate}}</div>{{end}}
</div>
{{end}}
</div>
{{else}}
<p class="empty-message">No software information available.</p>
{{end}}
</section>
<section class="info-card">
<div class="card-header">Install from URL</div>
<div class="card-body">
<p class="fw-help-text">
Get the latest firmware from
<a href="https://github.com/kernelkit/infix/releases/latest" target="_blank" rel="noopener">GitHub Releases</a>.
Download the <code>.tar.gz</code> tarball, extract the <code>.pkg</code> file, and serve it over FTP/HTTP.
</p>
<details class="fw-hint">
<summary>How to host the firmware from your PC</summary>
<div class="fw-hint-body">
<p>Extract and serve the package with Python (Linux/macOS/Windows):</p>
<code class="fw-hint-code">tar xf infix-{{if .Machine}}{{.Machine}}{{else}}aarch64{{end}}-vVERSION.tar.gz
python3 -m http.server 8080</code>
<p>Then enter the URL below pointing at the <code>.pkg</code> file on your machine.</p>
</div>
</details>
<form hx-post="/firmware/install" hx-swap="none" class="firmware-form">
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
<div class="form-group">
<label for="fw-url">Firmware Package URL (.pkg)</label>
<input type="url" id="fw-url" name="url"
placeholder="http://192.168.1.10:8080/infix-{{if .Machine}}{{.Machine}}{{else}}aarch64{{end}}-vVERSION.pkg"
list="fw-url-history" autocomplete="off" required>
<datalist id="fw-url-history"></datalist>
</div>
<label class="fw-checkbox-row">
<input type="checkbox" name="auto-reboot" value="1" id="fw-auto-reboot">
Reboot after successful firmware upgrade
</label>
<button type="submit" class="btn btn-primary"
hx-confirm="Install firmware from this URL?"
{{if .Installer}}{{if .Installer.Active}}disabled{{end}}{{end}}>Install</button>
</form>
</div>
</section>
<section class="info-card fw-card-muted">
<div class="card-header">Upload &amp; Install <span class="badge badge-neutral">Coming Soon</span></div>
<div class="card-body">
<p class="fw-help-text">
Direct file upload is not yet available. As a workaround, serve the
<code>.pkg</code> file from your computer using a local HTTP server (see the URL installer).
</p>
<div class="fw-upload-placeholder">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span>Drop .pkg file here or click to browse</span>
</div>
<button class="btn btn-secondary" disabled>Upload &amp; Install</button>
</div>
</section>
</div>
{{end}}
{{define "fw-progress-body"}}
{{if and .Installer .Installer.Done}}
{{if .Installer.Success}}
<div class="card-header">Install Complete</div>
<div class="card-body fw-result-body">
<div class="fw-result fw-result-ok">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span>Firmware installed successfully.</span>
</div>
{{if not .AutoReboot}}
<div class="fw-result-actions">
<button class="btn btn-primary fw-reboot-btn"
hx-post="/reboot"
hx-target="#content"
hx-swap="innerHTML">Reboot to activate</button>
</div>
{{end}}
</div>
{{else}}
<div class="card-header">Install Failed</div>
<div class="card-body">
<div class="fw-result fw-result-err">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>
<span>{{.Installer.LastError}}</span>
</div>
</div>
{{end}}
{{else}}
<div class="card-header">Install in Progress</div>
<div class="card-body">
<div class="progress-bar-wrap progress-bar-wrap--flush">
<div class="progress-bar" data-progress="{{if .Installer}}{{.Installer.Percentage}}{{end}}"></div>
</div>
<p class="progress-text">{{if .Installer}}{{.Installer.Percentage}}% &mdash; {{.Installer.Message}}{{else}}Installing&hellip;{{end}}</p>
</div>
{{end}}
{{end}}