Add a dashboard and backup download to the web UI

Metric history (5s to 1h tiers, persisted), uplink status, ping targets,
DHCP leases, and a tar.gz backup of the state directory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-29 13:34:21 +01:00
co-authored by Claude Sonnet 5.5
parent e684ddb06c
commit 2a111d012b
13 changed files with 1835 additions and 37 deletions
+23 -1
View File
@@ -1,9 +1,31 @@
{{define "content"}}
<section>
<h2>Settings</h2>
<p>The complete settings: the <code>router</code> options of nixos-router as JSON, plus <code>system</code>.
They are checked when applied; nothing changes until then.</p>
<form method="post" action="/advanced">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<textarea name="settings" rows="30" spellcheck="false">{{.Data}}</textarea>
<textarea name="settings" rows="30" spellcheck="false">{{.Data.Settings}}</textarea>
<button>Save</button>
</form>
</section>
<section>
<h2>Dashboard</h2>
<form method="post" action="/prefs" class="narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Ping targets <input name="pingTargets" value="{{.Data.PingTargets}}" spellcheck="false"></label>
<p class="hint">Up to 5 IP addresses, separated by commas. <code>gateway</code> is the provider's end of the line.
Takes effect right away; no apply needed.</p>
<button>Save</button>
</form>
</section>
<section>
<h2>Backup</h2>
<p>Everything on <code>/var/lib/router</code>: settings, PPPoE password, WireGuard keys, passwords, the web UI's
certificate and the dashboard history. Keep the file safe. To restore, put it on a USB stick and run
<code>router-setup</code> from the installer: it offers to install from it.</p>
<form method="post" action="/backup"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Download backup</button></form>
</section>
{{end}}
@@ -0,0 +1,82 @@
{{define "dashboard"}}
{{$d := .Data.Dashboard}}{{$u := $d.Snap.Uplink}}{{$sys := $d.Snap.System}}
<div class="ranges" role="group" aria-label="Time range">
{{range $d.Ranges}}<a href="/?range={{.}}" data-range="{{.}}"{{if eq . $d.Range}} class="active" aria-current="true"{{end}}>{{.}}</a>{{end}}
</div>
<div class="cards">
{{if $d.Snap.Time.IsZero}}
<section class="card"><h2>Internet</h2><p class="state">Collecting…</p></section>
{{else}}
<section class="card {{if and $u.Connected (or $u.Reachable (not $u.Checked))}}up{{else if $u.Connected}}degraded{{else}}down{{end}}">
<h2>Internet</h2>
<p class="state">
{{if and $u.Connected (or $u.Reachable (not $u.Checked))}}Connected
{{else if $u.Connected}}Connected, but no answer from the internet
{{else if not $u.Exists}}{{if eq $u.Type "pppoe"}}Not connected: no PPPoE session{{else}}Not connected: {{$u.Device}} is missing{{end}}
{{else}}Not connected{{end}}
{{if not $u.Since.IsZero}}<span class="muted">for {{ago $u.Since}}</span>{{end}}
</p>
<dl>
<dt>Uplink</dt><dd>{{$u.Device}} ({{if eq $u.Type "pppoe"}}PPPoE{{else}}DHCP{{end}})</dd>
{{if $u.IPv4}}<dt>IPv4</dt><dd>{{join $u.IPv4 ", "}}</dd>{{end}}
{{if $u.IPv6}}<dt>IPv6</dt><dd>{{join $u.IPv6 ", "}}</dd>{{end}}
{{if $u.Gateway}}<dt>Gateway</dt><dd>{{$u.Gateway}}</dd>{{end}}
{{range $d.Snap.Pings}}
<dt>Ping {{.Target}}</dt><dd>{{if .OK}}{{ms .RTT}}{{else if .Address}}<span class="bad">no answer</span>{{else}}<span class="muted">no address</span>{{end}}{{if and .Address (ne .Address .Target)}} <span class="muted">{{.Address}}</span>{{end}}</dd>
{{end}}
</dl>
</section>
{{end}}
<section class="card">
<h2>System</h2>
<dl>
<dt>Uptime</dt><dd>{{age $sys.Uptime}}</dd>
<dt>Load</dt><dd>{{$sys.Load}}</dd>
<dt>CPU</dt><dd>{{value $sys.CPU "%"}}</dd>
{{if $sys.MemTotal}}<dt>Memory</dt><dd>{{bytes $sys.MemUsed}} of {{bytes $sys.MemTotal}}</dd>{{end}}
{{if not (isNaN $sys.Temperature)}}<dt>Temperature</dt><dd>{{value $sys.Temperature "°C"}}</dd>{{end}}
{{if $sys.DiskTotal}}<dt>Disk</dt><dd>{{bytes $sys.DiskUsed}} of {{bytes $sys.DiskTotal}}</dd>{{end}}
</dl>
</section>
{{if $d.Events}}
<section class="card">
<h2>Connection log</h2>
<ul class="events">
{{range $d.Events}}<li><span class="{{if .Connected}}ok{{else}}bad{{end}}">{{if .Connected}}up{{else}}down{{end}}</span> {{.Time.Local.Format "Jan 2 15:04:05"}}{{if .Detail}} <span class="muted">{{.Detail}}</span>{{end}}</li>{{end}}
</ul>
</section>
{{end}}
</div>
<section>
<h2>DHCP clients</h2>
{{if $d.Leases}}
<div class="scroll">
<table class="leases">
<tr><th>Name</th><th>Address</th><th>MAC</th><th>LAN</th><th>Lease ends in</th></tr>
{{range $d.Leases}}<tr><td>{{or .Hostname "–"}}</td><td>{{.Address}}</td><td><code>{{.MAC}}</code></td><td>{{.LAN}}</td><td>{{until .Expires}}</td></tr>{{end}}
</table>
</div>
{{else}}<p class="muted">No active leases.</p>{{end}}
</section>
<div class="charts">
{{range $d.Charts}}
<figure class="chart">
<figcaption><strong>{{.Title}}</strong>
<span class="legend">{{range .Lines}}<span class="key {{.Class}}">{{.Label}} <b>{{.Now}}</b></span>{{end}}</span>
</figcaption>
<svg viewBox="-64 -8 690 150" role="img" aria-label="{{.Title}}">
{{range .YTicks}}<line class="grid" x1="0" x2="{{chartW}}" y1="{{.Pos}}" y2="{{.Pos}}"/><text class="lab" x="-6" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>{{end}}
{{range .XTicks}}<text class="lab" x="{{.Pos}}" y="138" text-anchor="middle">{{.Label}}</text>{{end}}
{{range .Lines}}<path class="line {{.Class}}" d="{{.Path}}"/>{{end}}
{{if .Empty}}<text class="lab" x="300" y="64" text-anchor="middle">no data yet</text>{{end}}
{{range .Cols}}<rect class="hover" x="{{.X}}" y="0" width="{{.W}}" height="{{chartH}}"><title>{{.Tip}}</title></rect>{{end}}
</svg>
</figure>
{{end}}
</div>
{{end}}
+22 -12
View File
@@ -1,12 +1,5 @@
{{define "content"}}
{{$s := .Data}}
<section>
<dl>
<dt>Running system</dt><dd><code>{{short $s.Current}}</code></dd>
<dt>Confirmed system</dt><dd><code>{{short $s.Confirmed}}</code></dd>
</dl>
</section>
{{$s := .Data.Status}}
{{if $s.Applying}}
<section class="busy"><h2>Applying…</h2><p>Building the new configuration. This page refreshes on its own.</p></section>
{{else if $s.ConfirmRunning}}
@@ -18,20 +11,37 @@
the router goes back to the confirmed system on its own.</p>
<form method="post" action="/confirm"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Confirm</button></form>
</section>
{{else}}
<section>
{{if $s.Unapplied}}<p>The settings have changes that are not part of the confirmed system.</p>{{else}}<p>The settings match the confirmed system.</p>{{end}}
{{else if $s.Unapplied}}
<section class="pending">
<p>The settings have changes that are not part of the confirmed system.</p>
<div class="actions">
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Apply</button></form>
{{if $s.Unapplied}}<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Discard changes</button></form>{{end}}
<form method="post" action="/discard"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Discard changes</button></form>
</div>
</section>
{{end}}
<div id="dashboard" data-range="{{.Data.Dashboard.Range}}" data-interval="{{.Data.Dashboard.Interval}}">
{{template "dashboard" .}}
</div>
<section>
<h2>Configuration</h2>
{{if not (or $s.Applying $s.ConfirmRunning $s.Unapplied (gt (seconds $s.RollbackIn) 0))}}<p>The settings match the confirmed system.</p>{{end}}
<dl>
<dt>Running system</dt><dd><code>{{short $s.Current}}</code></dd>
<dt>Confirmed system</dt><dd><code>{{short $s.Confirmed}}</code></dd>
</dl>
{{if not (or $s.Applying $s.ConfirmRunning $s.Unapplied (gt (seconds $s.RollbackIn) 0))}}
<form method="post" action="/apply"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Rebuild and apply</button></form>
{{end}}
</section>
{{if $s.Log}}
<section>
<h2>Last apply{{if $s.ApplyResult}} — {{if eq $s.ApplyResult "success"}}succeeded{{else}}failed ({{$s.ApplyResult}}){{end}}{{end}}</h2>
<pre class="log">{{$s.Log}}</pre>
</section>
{{end}}
<script src="/static/dashboard.js" defer></script>
{{end}}