mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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…</p>
|
||||
<p class="reboot-status" id="reboot-status">Waiting for device to shut down…</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 — disable NTP first under Configure > 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">✓ 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…</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">✓ 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…</p>
|
||||
<p class="reboot-status" id="reboot-status">Wiping configuration and rebooting…</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">✓ 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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, '&').replace(/"/g, '"') + '">';
|
||||
}).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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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}}—{{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 & 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 & 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}}% — {{.Installer.Message}}{{else}}Installing…{{end}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user