webui: refactor maintenance section

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-14 22:07:41 +02:00
parent 1a33604ed6
commit f9733dc52d
10 changed files with 341 additions and 50 deletions
+3
View File
@@ -1,2 +1,5 @@
RESTCONF_URL=https://127.0.0.1/restconf
INSECURE_TLS=1
# Spool firmware uploads (and any other temp files) to persistent storage
# instead of the RAM-backed /tmp to enable push-upgrades on low-memory systems.
TMPDIR=/var/tmp
@@ -47,10 +47,10 @@ int main(int argc, char **argv)
json_object_set_new(json, "last-error", json_string(strval));
props = rauc_installer_get_progress(rauc);
if(props) {
GVariant *val;
gint32 pct;
progress = json_object();
g_variant_get(props, "(@isi)", &val, &strval, NULL);
json_object_set_new(progress, "percentage", json_string(g_variant_print(val, FALSE)));
g_variant_get(props, "(isi)", &pct, &strval, NULL);
json_object_set_new(progress, "percentage", json_integer(pct));
json_object_set_new(progress, "message", json_string(strval));
json_object_set_new(json, "progress", progress);
}
+24 -5
View File
@@ -56,6 +56,14 @@ func New(
if err != nil {
return nil, err
}
sysCtrlTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/system-control.html")
if err != nil {
return nil, err
}
backupTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/backup.html")
if err != nil {
return nil, err
}
routingTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/routing.html")
if err != nil {
return nil, err
@@ -155,8 +163,10 @@ func New(
}
sys := &handlers.SystemHandler{
RC: rc,
Template: fwrTmpl,
RC: rc,
Template: fwrTmpl,
SysCtrlTmpl: sysCtrlTmpl,
BackupTmpl: backupTmpl,
}
routing := &handlers.RoutingHandler{Template: routingTmpl, RC: rc}
@@ -203,10 +213,19 @@ func New(
})
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)
mux.HandleFunc("POST /firmware/install", sys.FirmwareInstall)
mux.HandleFunc("POST /firmware/upload", sys.FirmwareUpload)
mux.HandleFunc("POST /firmware/boot-order", sys.SetBootOrder)
mux.HandleFunc("POST /reboot", sys.Reboot) // kept for firmware page "Reboot to activate"
mux.HandleFunc("GET /config", sys.DownloadConfig)
mux.HandleFunc("GET /maintenance/backup", sys.Backup)
mux.HandleFunc("POST /maintenance/backup/restore", sys.RestoreConfig)
mux.HandleFunc("GET /maintenance/system", sys.SystemControl)
mux.HandleFunc("POST /maintenance/system/reboot", sys.Reboot)
mux.HandleFunc("POST /maintenance/system/shutdown", sys.Shutdown)
mux.HandleFunc("POST /maintenance/system/factory-default", sys.FactoryDefault)
mux.HandleFunc("POST /maintenance/system/factory-reset", sys.FactoryReset)
mux.HandleFunc("POST /maintenance/system/datetime", sys.SetDatetime)
mux.HandleFunc("GET /routing", routing.Overview)
mux.HandleFunc("GET /wifi", wifi.Overview)
mux.HandleFunc("GET /vpn", vpn.Overview)
+86 -18
View File
@@ -42,6 +42,10 @@
--warning: var(--amber-500);
--danger: var(--red-500);
--warning-bg: #fffbeb;
--warning-fg: #78350f;
--warning-border: #fcd34d;
/* Sidebar — Light mode */
--sidebar-bg: var(--slate-100);
--sidebar-fg: var(--slate-700);
@@ -84,6 +88,9 @@
--sidebar-hover-fg: #fff;
--sidebar-border: rgba(255, 255, 255, 0.1);
--dot-color: rgba(255, 255, 255, 0.045);
--warning-bg: #2d2007;
--warning-fg: #fde68a;
--warning-border: #92400e;
}
}
@@ -104,6 +111,9 @@
--sidebar-hover-fg: #fff;
--sidebar-border: rgba(255, 255, 255, 0.1);
--dot-color: rgba(255, 255, 255, 0.045);
--warning-bg: #2d2007;
--warning-fg: #fde68a;
--warning-border: #92400e;
}
/* Force Light Mode (override system dark preference) */
@@ -122,6 +132,9 @@
--sidebar-hover-fg: var(--primary);
--sidebar-border: rgba(0, 0, 0, 0.08);
--dot-color: rgba(0, 0, 0, 0.055);
--warning-bg: #fffbeb;
--warning-fg: #78350f;
--warning-border: #fcd34d;
}
.light .alert-error { background: #fef2f2; color: #991b1b; border-color: #fecaca; }
.light .alert-info { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
@@ -742,9 +755,11 @@ details[open].nav-group-top > summary.nav-group-summary-top::before {
padding: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
position: sticky;
top: 0;
z-index: 50;
position: fixed;
top: var(--topbar-height);
left: 0;
right: 0;
z-index: 100;
}
.alert {
@@ -1466,6 +1481,11 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
margin-right: 0.2rem;
}
.fw-boot-slots { display: flex; gap: 0.3rem; align-items: center; flex: 1; }
.fw-boot-badge { cursor: grab; user-select: none; }
.fw-boot-badge.fw-boot-dragging { opacity: 0.35; }
.fw-boot-badge.fw-boot-drop-before { box-shadow: -3px 0 0 var(--primary); }
.fw-slot-list { display: flex; flex-direction: column; }
.fw-slot-item {
padding: 0.75rem 1.25rem;
@@ -2012,24 +2032,12 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
align-items: center;
gap: 0.6rem;
padding: 0.5rem 1.25rem;
background: #fffbeb;
border-bottom: 1px solid #fcd34d;
color: #78350f;
background: var(--warning-bg);
border-bottom: 1px solid var(--warning-border);
color: var(--warning-fg);
font-size: 0.875rem;
flex-shrink: 0;
}
.dark .cfg-unsaved-banner {
background: #2d2007;
border-color: #92400e;
color: #fde68a;
}
@media (prefers-color-scheme: dark) {
.cfg-unsaved-banner {
background: #2d2007;
border-color: #92400e;
color: #fde68a;
}
}
/* cfgError inline feedback */
.cfg-save-status.error { color: var(--danger); cursor: pointer; }
@@ -2177,6 +2185,66 @@ select.cfg-input {
.dark .cfg-save-status.saved { color: #86efac; }
.light .cfg-save-status.saved { color: #16a34a; }
/* ==========================================================================
System Control page
========================================================================== */
.sc-action-body {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
gap: 1rem;
}
.sc-desc {
color: var(--fg-muted);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
.sc-danger-card { border-color: var(--warning-border); }
.sc-danger-body { display: flex; flex-direction: column; }
.sc-danger-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding: 1.25rem;
flex-wrap: wrap;
}
.sc-danger-text { flex: 1; min-width: 0; }
.sc-danger-text strong { display: block; margin-bottom: 0.35rem; }
.sc-danger-sep { height: 1px; background: var(--border); margin: 0 1.25rem; }
.sc-fd-ok { color: var(--success); font-size: 0.875rem; }
.sc-fd-err { color: var(--danger); font-size: 0.875rem; }
.sc-dt-row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.sc-restore-label {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.75rem;
cursor: pointer;
}
.sc-restore-warn-text {
color: var(--warning-fg);
font-size: 0.875rem;
}
/* ==========================================================================
YANG Tree Navigation
========================================================================== */
+34
View File
@@ -135,6 +135,12 @@
if (!slots || slots.dataset.dndInit) return;
slots.dataset.dndInit = 'true';
// Stash the page-load order so Reset can restore it without a page refresh.
// Slot names are a fixed enum (primary | secondary | net), so comma-joining is safe.
var initialOrder = [];
slots.querySelectorAll('.fw-boot-badge').forEach(function (b) { initialOrder.push(b.dataset.slot); });
slots.dataset.originalOrder = initialOrder.join(',');
var dragging = null;
var insertRef = undefined; // node to insertBefore; undefined = not set, null = append
@@ -189,8 +195,28 @@
var saveBtn = document.getElementById('fw-boot-save-btn');
if (saveBtn) saveBtn.addEventListener('click', function () { window.fwBootSave(saveBtn); });
var resetBtn = document.getElementById('fw-boot-reset-btn');
if (resetBtn) resetBtn.addEventListener('click', window.fwBootReset);
}
// Restore the boot-order row to the page-load order — i.e. what RAUC
// reported as the current device boot order before any drag/drop.
// Set will push the displayed order to the device; Reset just undoes
// local rearrangement without a server round-trip.
window.fwBootReset = function () {
var slots = document.getElementById('fw-boot-slots');
if (!slots) return;
var original = (slots.dataset.originalOrder || '').split(',');
var existing = {};
slots.querySelectorAll('.fw-boot-badge').forEach(function (b) {
existing[b.dataset.slot] = b;
});
original.forEach(function (slot) {
if (existing[slot]) slots.appendChild(existing[slot]);
});
};
window.fwBootSave = function (btn) {
var badges = document.querySelectorAll('#fw-boot-slots .fw-boot-badge');
var params = new URLSearchParams();
@@ -213,6 +239,14 @@
body: params.toString(),
}).then(function (r) {
if (r.ok) {
// Device now holds the displayed order; refresh the Reset baseline
// so a follow-up \u21ba doesn't revert to a state the device no longer has.
var slots = document.getElementById('fw-boot-slots');
if (slots) {
var saved = [];
badges.forEach(function (b) { saved.push(b.dataset.slot); });
slots.dataset.originalOrder = saved.join(',');
}
btnSet('\u2713 Set', true);
setTimeout(function () { btnSet('Set', false); }, 2000);
return;
+1
View File
@@ -16,6 +16,7 @@
<body hx-boost="true">
<div id="page-progress" class="page-progress" aria-hidden="true"></div>
<div id="conn-banner" class="conn-banner" hidden>Device unreachable</div>
<div hidden hx-get="/device-status" hx-trigger="every 5s" hx-swap="none"></div>
<div class="topbar">
<button id="hamburger-btn" class="hamburger-btn" aria-label="Open menu" aria-expanded="false">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+14 -10
View File
@@ -218,20 +218,24 @@
</a>
</li>
<li>
<a href="/config" class="nav-link" hx-boost="false" download>
<a href="/maintenance/backup"
hx-get="/maintenance/backup"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "backup"}}active{{end}}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
Download Config
Backup &amp; Restore
</a>
</li>
<li>
<button class="nav-link nav-link-btn nav-link-danger"
hx-post="/reboot"
hx-confirm="Reboot the device?"
hx-target="#content"
hx-swap="innerHTML">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
Reboot
</button>
<a href="/maintenance/system"
hx-get="/maintenance/system"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "system-control"}}active{{end}}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
System Control
</a>
</li>
</ul>
</details>
+48
View File
@@ -0,0 +1,48 @@
{{define "backup.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
<div class="info-grid">
<section class="info-card">
<div class="card-header">Backup</div>
<div class="card-body sc-action-body">
<p class="sc-desc">Download the current startup configuration as a JSON file.
The file format is <pre>startup-config-$hostname-$date-$time.cfg</pre>
</p>
<div>
<a href="/config" hx-boost="false" class="btn btn-primary">Download config</a>
</div>
</div>
</section>
<section class="info-card">
<div class="card-header">Restore</div>
<div class="card-body sc-action-body">
<p class="sc-desc">Restore a previously backed-up configuration. By default
the restore operation is made to running config, this is the safe default.</p>
<form id="restore-form"
hx-post="/maintenance/backup/restore"
hx-target="#restore-result"
hx-swap="innerHTML"
hx-encoding="multipart/form-data"
hx-confirm="Apply this configuration to the running system?"
hx-headers='{"X-CSRF-Token": "{{.CsrfToken}}"}'>
<div class="sc-dt-row">
<input type="file" name="config" accept=".json" required class="cfg-input">
</div>
<label class="sc-restore-label">
<input type="checkbox" name="save-to-startup" id="restore-startup-cb">
<span class="sc-restore-warn-text">Save to startup — may break system boot!</span>
</label>
<div id="restore-result"></div>
<div style="margin-top:0.75rem">
<button type="submit" class="btn btn-primary">Restore</button>
</div>
</form>
</div>
</section>
</div>
{{end}}
+30 -14
View File
@@ -11,8 +11,9 @@
<div class="alert alert-info">{{.Message}}</div>
{{end}}
{{if .Installing}}
{{$sseURL := "/firmware/progress"}}{{if .AutoReboot}}{{$sseURL = "/firmware/progress?auto-reboot=1"}}{{end}}
{{if .Installing}}
<section id="fw-progress-card" class="info-card" data-sse-src="{{$sseURL}}">
{{template "fw-progress-body" .}}
</section>
@@ -25,7 +26,14 @@
{{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}}
<span id="fw-boot-slots" class="fw-boot-slots">
{{range .BootOrder}}<span class="badge badge-neutral fw-boot-badge" draggable="true" data-slot="{{.}}">{{.}}</span>{{end}}
</span>
<button type="button" class="btn btn-sm btn-outline"
id="fw-boot-save-btn">Set</button>
<button type="button" class="btn btn-secondary btn-sm"
id="fw-boot-reset-btn"
title="Restore current device boot order"></button>
</div>
{{end}}
{{if .Slots}}
@@ -42,6 +50,8 @@
</div>
{{end}}
</div>
{{else if .Installing}}
<p class="empty-message">Slot details paused during install.</p>
{{else}}
<p class="empty-message">No software information available.</p>
{{end}}
@@ -84,25 +94,31 @@ python3 -m http.server 8080</code>
</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>
<section class="info-card">
<div class="card-header">Upload &amp; Install</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).
Select a <code>.pkg</code> firmware file from your computer to upload and install directly.
</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 class="form-group">
<label for="fw-file">Firmware Package (.pkg)</label>
<input type="file" id="fw-file" accept=".pkg" class="cfg-input" required>
</div>
<label class="fw-checkbox-row">
<input type="checkbox" id="fw-upload-auto-reboot">
Reboot after successful firmware upgrade
</label>
<div style="margin-top:0.75rem">
<button type="button" class="btn btn-primary" id="fw-upload-btn"
data-sse-url="{{$sseURL}}"
{{if and .Installer .Installer.Active}}disabled{{end}}>
Upload &amp; Install
</button>
</div>
<button class="btn btn-secondary" disabled>Upload &amp; Install</button>
</div>
</section>
</div>
{{end}}
@@ -0,0 +1,98 @@
{{define "system-control.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
<div class="info-grid">
<section class="info-card">
<div class="card-header">Reboot</div>
<div class="card-body sc-action-body">
<p class="sc-desc">Restart the device. The running configuration is preserved.
Any unsaved changes (running config not yet copied to startup) will be lost on reboot.</p>
<div>
<button class="btn btn-primary"
hx-post="/maintenance/system/reboot"
hx-confirm="Reboot the device?"
hx-target="#content"
hx-swap="innerHTML">Reboot</button>
</div>
</div>
</section>
<section class="info-card">
<div class="card-header">Shutdown</div>
<div class="card-body sc-action-body">
<p class="sc-desc">Power off the device. Physical access is required to bring it back online.</p>
<div>
<button class="btn btn-secondary"
hx-post="/maintenance/system/shutdown"
hx-confirm="Shut down the device?"
hx-target="#content"
hx-swap="innerHTML">Shut down</button>
</div>
</div>
</section>
<section class="info-card">
<div class="card-header">Date &amp; Time</div>
<div class="card-body sc-action-body">
<p class="sc-desc">
{{if .CurrentDatetime}}Device clock: <strong>{{.CurrentDatetime}}</strong>.{{else}}Device clock unavailable.{{end}}
</p>
<form hx-post="/maintenance/system/datetime"
hx-target="#sc-dt-result"
hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
<div class="sc-dt-row">
<input type="datetime-local" id="sc-dt-input" name="datetime"
class="cfg-input" required>
<button type="button" id="sc-sync-time-btn"
class="btn btn-outline btn-sm">Browser time</button>
</div>
<div id="sc-dt-result"></div>
<div style="margin-top:0.75rem">
<button type="submit" class="btn btn-primary">Set time</button>
</div>
</form>
</div>
</section>
<section class="info-card info-grid-full sc-danger-card">
<div class="card-header">Factory Reset</div>
<div class="sc-danger-body">
<div class="sc-danger-item">
<div class="sc-danger-text">
<strong>Reset running config</strong>
<p class="sc-desc">Replaces the running configuration with factory defaults.
No reboot — takes effect immediately. Use the persistent notification to save to startup.</p>
<div id="sc-fd-result"></div>
</div>
<button class="btn btn-danger-outline"
hx-post="/maintenance/system/factory-default"
hx-confirm="Reset running configuration to factory defaults? This cannot be undone."
hx-target="#sc-fd-result"
hx-swap="innerHTML">Reset running config</button>
</div>
<div class="sc-danger-sep"></div>
<div class="sc-danger-item">
<div class="sc-danger-text">
<strong>Factory reset &amp; reboot</strong>
<p class="sc-desc">Wipes all configuration and non-volatile storage, then reboots.
The device may become unreachable on the network. This cannot be undone.</p>
</div>
<button class="btn btn-danger-outline"
hx-post="/maintenance/system/factory-reset"
hx-confirm="Wipe all configuration and reboot? This cannot be undone."
hx-target="#content"
hx-swap="innerHTML">Factory reset</button>
</div>
</div>
</section>
</div>
{{end}}