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:
@@ -0,0 +1,45 @@
|
||||
// Live updates for the overview: refetches the dashboard part of the page
|
||||
// (rendered by the server, like the page itself) and switches time ranges
|
||||
// without a reload. Without JavaScript the page and its links still work.
|
||||
(function () {
|
||||
"use strict";
|
||||
const root = document.getElementById("dashboard");
|
||||
if (!root) return;
|
||||
let range = root.dataset.range;
|
||||
let interval = Number(root.dataset.interval) || 5;
|
||||
let timer = null;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch("/dashboard?range=" + encodeURIComponent(range), { credentials: "same-origin" });
|
||||
if (r.redirected || r.status === 401 || r.status === 403) {
|
||||
location.href = "/login"; // the session ended
|
||||
return;
|
||||
}
|
||||
if (r.ok) root.innerHTML = await r.text();
|
||||
} catch (e) {
|
||||
// The router may be restarting the UI (an apply); try again later.
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
if (!document.hidden) timer = setTimeout(refresh, interval * 1000);
|
||||
}
|
||||
|
||||
root.addEventListener("click", function (ev) {
|
||||
const a = ev.target.closest("a[data-range]");
|
||||
if (!a) return;
|
||||
ev.preventDefault();
|
||||
range = a.dataset.range;
|
||||
interval = range === "1h" ? 5 : 60;
|
||||
history.replaceState(null, "", "/?range=" + encodeURIComponent(range));
|
||||
refresh();
|
||||
});
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.hidden) clearTimeout(timer);
|
||||
else refresh();
|
||||
});
|
||||
schedule();
|
||||
})();
|
||||
@@ -35,3 +35,36 @@ button.link { background: none; border: none; color: var(--accent); padding: 0;
|
||||
.narrow label { margin: 0; }
|
||||
pre.log { background: color-mix(in srgb, var(--line) 35%, transparent); padding: .75rem; border-radius: 6px; overflow-x: auto; font-size: 12.5px; max-height: 30rem; }
|
||||
code { font-size: .9em; overflow-wrap: anywhere; }
|
||||
.muted { color: var(--muted); }
|
||||
.bad { color: var(--bad); }
|
||||
.ok { color: var(--good); }
|
||||
:root { --good: #1e7b34; --s0: #2457c5; --s1: #d9730d; --s2: #2f9e44; --s3: #9c36b5; --s4: #c92a2a; }
|
||||
@media (prefers-color-scheme: dark) { :root { --good: #69db7c; --s0: #7aa2ff; --s1: #ffa94d; --s2: #69db7c; --s3: #da77f2; --s4: #ff8787; } }
|
||||
.ranges { display: flex; gap: .25rem; margin: 0 0 1rem; }
|
||||
.ranges a { padding: .2rem .7rem; border: 1px solid var(--line); border-radius: 4px; text-decoration: none; }
|
||||
.ranges a.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1rem; margin: 0 0 1.25rem; }
|
||||
.card { border: 1px solid var(--line); border-left-width: 4px; border-radius: 6px; padding: .75rem 1rem; margin: 0; }
|
||||
.card.up { border-left-color: var(--good); }
|
||||
.card.degraded { border-left-color: #e8a600; }
|
||||
.card.down { border-left-color: var(--bad); }
|
||||
.card .state { font-weight: 600; margin: 0 0 .5rem; }
|
||||
.card dl { margin: 0; font-size: .92em; }
|
||||
.events { list-style: none; margin: 0; padding: 0; font-size: .9em; }
|
||||
.events li span:first-child { display: inline-block; width: 2.5rem; font-weight: 600; }
|
||||
.scroll { overflow-x: auto; }
|
||||
.leases td, .leases th { padding: .2rem .75rem .2rem 0; white-space: nowrap; }
|
||||
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 30rem), 1fr)); gap: 1rem; margin: 0 0 1.5rem; }
|
||||
.chart { margin: 0; border: 1px solid var(--line); border-radius: 6px; padding: .5rem .75rem; min-width: 0; }
|
||||
.chart figcaption { display: flex; flex-wrap: wrap; gap: .25rem 1rem; justify-content: space-between; font-size: .9em; }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: .1rem .9rem; }
|
||||
.key::before { content: ""; display: inline-block; width: .7rem; height: .2rem; margin: 0 .3rem .2rem 0; background: currentColor; vertical-align: middle; }
|
||||
.key b { font-weight: 500; color: var(--fg); }
|
||||
.chart svg { width: 100%; height: auto; display: block; overflow: visible; }
|
||||
.chart .grid { stroke: var(--line); stroke-width: 1; }
|
||||
.chart .lab { fill: var(--muted); font-size: 11px; }
|
||||
.chart .line { fill: none; stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.chart .hover { fill: transparent; }
|
||||
.chart .hover:hover { fill: color-mix(in srgb, var(--fg) 8%, transparent); }
|
||||
.s0 { color: var(--s0); stroke: var(--s0); } .s1 { color: var(--s1); stroke: var(--s1); } .s2 { color: var(--s2); stroke: var(--s2); }
|
||||
.s3 { color: var(--s3); stroke: var(--s3); } .s4 { color: var(--s4); stroke: var(--s4); }
|
||||
|
||||
Reference in New Issue
Block a user