dashboard: live cache-age indicator

The web server's dashboardStats now reports how stale its shared cache
snapshot is — data.cache = { enabled, ttlMs, ageMs }, using the age of the
oldest dataset feeding the view (OLT-STATE, plus ONU-STATE under detailed
scan). cache.js gains ageOf(key).

The dashboard renders a live-ticking badge from it (renderCacheBadge):
  - within TTL  -> "◷ cached Ns ago" (info)
  - past TTL    -> same, warn-coloured (next load will refetch)
  - no cache    -> "● live" (Electron app, or PFW_CACHE_TTL_SECONDS=0)
The Dashboard Refresh button now sends fresh:true to bypass the cache and
reset the age; the detailed-stats toggle and auto-loads use the cache.

Shared renderer change, so the Electron app shows "live" (it sends no cache
field). Verified offscreen over HTTP: cached / stale / live states render
with the right text + colour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-23 14:25:46 +02:00
parent 6ee1d37a79
commit c2206ad5a7
6 changed files with 84 additions and 5 deletions

View file

@ -803,6 +803,11 @@ Consequences for editing:
read-modify-write fetches and the FEC pre-flight stay **uncached** (must be read-modify-write fetches and the FEC pre-flight stay **uncached** (must be
live). Writes call `invalidate(session, [...])` for the affected datasets. live). Writes call `invalidate(session, [...])` for the affected datasets.
This cache is web-only; the single-user Electron app doesn't need it. This cache is web-only; the single-user Electron app doesn't need it.
`dashboardStats` attaches `data.cache = { enabled, ttlMs, ageMs }` (age of
the oldest dataset feeding the view); the dashboard renders a live-ticking
age badge from it (`renderCacheBadge`), and the Dashboard **Refresh** button
sends `fresh:true` to bypass the cache. The Electron app sends no cache
field, so the badge shows "live".
- **Deploy** lives in `web/deploy/` (systemd unit + env template + - **Deploy** lives in `web/deploy/` (systemd unit + env template +
`update.sh`); Debian-13-LXC clone-to-run steps and the Nginx Proxy Manager `update.sh`); Debian-13-LXC clone-to-run steps and the Nginx Proxy Manager
setup are in `web/README.md`. Note streams send `X-Accel-Buffering: no` so setup are in `web/README.md`. Note streams send `X-Accel-Buffering: no` so

View file

@ -492,6 +492,20 @@ button.danger:hover:not(:disabled) {
} }
.dash-toggle { text-transform: none; letter-spacing: 0; color: var(--fg-dim); font-size: 12px; } .dash-toggle { text-transform: none; letter-spacing: 0; color: var(--fg-dim); font-size: 12px; }
/* Cache-age badge in the dashboard header. Colour comes from a status-*
class set in JS (info = within TTL, warn = stale, ok = live/no cache). */
.dash-cache {
font-size: 11px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.3px;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid currentColor;
white-space: nowrap;
cursor: default;
}
.dash-cache:empty { display: none; }
.dash-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } .dash-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.tile { .tile {
background: linear-gradient(180deg, rgba(33, 200, 255, 0.07), transparent 70%), var(--bg-elev); background: linear-gradient(180deg, rgba(33, 200, 255, 0.07), transparent 70%), var(--bg-elev);

View file

@ -1551,8 +1551,10 @@ $('#btn-olt-execute').addEventListener('click', async () => {
// -------- Dashboard: network telemetry -------- // -------- Dashboard: network telemetry --------
$('#dash-refresh').addEventListener('click', loadDashboard); // Refresh forces a fresh server fetch (bypasses the cache); the toggle and
$('#dash-extra').addEventListener('change', loadDashboard); // auto-loads use the cache. Wrap so the DOM event isn't passed as `force`.
$('#dash-refresh').addEventListener('click', () => loadDashboard(true));
$('#dash-extra').addEventListener('change', () => loadDashboard());
// Bit-rate formatter, ported from the iOS app's Format.bps. // Bit-rate formatter, ported from the iOS app's Format.bps.
function fmtBps(v) { function fmtBps(v) {
@ -1578,6 +1580,44 @@ function fmtDbm(n) {
return typeof n === 'number' ? n.toFixed(1) : '—'; return typeof n === 'number' ? n.toFixed(1) : '—';
} }
function fmtAge(ms) {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 90) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
return `${Math.floor(m / 60)}h ${m % 60}m`;
}
// Cache-age badge. The web server reports how old its shared cache snapshot
// is (`d.cache`); the Electron app sends no cache info → "live". The badge
// ticks every second so the operator can see the data going stale.
let dashCacheTimer = null;
function renderCacheBadge(cache) {
const el = $('#dash-cache');
if (!el) return;
if (dashCacheTimer) { clearInterval(dashCacheTimer); dashCacheTimer = null; }
if (!cache || !cache.enabled) {
el.className = 'dash-cache status-ok';
el.textContent = '● live';
el.title = 'Fetched directly from MCMS (no server-side cache).';
return;
}
const ttl = cache.ttlMs || 0;
const baseAge = cache.ageMs || 0;
const receivedAt = Date.now();
const paint = () => {
const age = baseAge + (Date.now() - receivedAt);
const stale = ttl > 0 && age > ttl;
el.className = 'dash-cache ' + (stale ? 'status-warn' : 'status-info');
el.textContent = `◷ cached ${fmtAge(age)} ago`;
el.title = stale
? `Shared server cache is past its ${Math.round(ttl / 1000)}s TTL — the next load refetches from MCMS.`
: `Shared server cache (TTL ${Math.round(ttl / 1000)}s) — collapses repeated MCMS requests across operators. Refresh to force-refetch.`;
};
paint();
dashCacheTimer = setInterval(paint, 1000);
}
function dashHealthRow(title, detail, count, drillKey) { function dashHealthRow(title, detail, count, drillKey) {
const cls = count > 0 ? 'status-warn' : 'status-ok'; const cls = count > 0 ? 'status-warn' : 'status-ok';
const tappable = drillKey && count > 0; const tappable = drillKey && count > 0;
@ -1613,13 +1653,13 @@ function showListModal(title, items) {
overlay.querySelector('.modal-ok').focus(); overlay.querySelector('.modal-ok').focus();
} }
async function loadDashboard() { async function loadDashboard(force) {
const extra = $('#dash-extra').checked; const extra = $('#dash-extra').checked;
$('#dash-status').textContent = extra $('#dash-status').textContent = extra
? 'Loading network + per-ONU Rx/FEC scan… (this fetches every ONU-STATE)' ? 'Loading network + per-ONU Rx/FEC scan… (this fetches every ONU-STATE)'
: 'Loading network overview…'; : 'Loading network overview…';
$('#dash-refresh').disabled = true; $('#dash-refresh').disabled = true;
const res = await window.api.dashboardStats({ extraStats: extra }); const res = await window.api.dashboardStats({ extraStats: extra, fresh: !!force });
$('#dash-refresh').disabled = false; $('#dash-refresh').disabled = false;
if (!res.ok) { if (!res.ok) {
$('#dash-status').textContent = `Error: ${res.error?.message || 'unknown'}`; $('#dash-status').textContent = `Error: ${res.error?.message || 'unknown'}`;
@ -1741,4 +1781,6 @@ function renderDashboard(d) {
const stamp = d.sampledAt ? new Date(d.sampledAt).toLocaleTimeString() : ''; const stamp = d.sampledAt ? new Date(d.sampledAt).toLocaleTimeString() : '';
$('#dash-status').textContent = $('#dash-status').textContent =
`${d.onuTotal} ONUs · ${d.oltCount} OLTs${d.extraStats ? ' · detailed scan' : ''}${stamp ? ' · ' + stamp : ''}`; `${d.onuTotal} ONUs · ${d.oltCount} OLTs${d.extraStats ? ' · detailed scan' : ''}${stamp ? ' · ' + stamp : ''}`;
renderCacheBadge(d.cache);
} }

View file

@ -49,6 +49,7 @@
<div class="fleet-head"> <div class="fleet-head">
<h2>Dashboard</h2> <h2>Dashboard</h2>
<div class="row"> <div class="row">
<span id="dash-cache" class="dash-cache" title=""></span>
<label class="inline dash-toggle"> <label class="inline dash-toggle">
<input id="dash-extra" type="checkbox" /> <input id="dash-extra" type="checkbox" />
Detailed stats (Rx / FEC scan) Detailed stats (Rx / FEC scan)

View file

@ -51,6 +51,12 @@ class TtlCache {
return p; return p;
} }
/** Age (ms) of the cached entry for `key`, or null if not cached. */
ageOf(key) {
const e = this.entries.get(key);
return e ? Date.now() - e.at : null;
}
/** Drop every entry whose key starts with `prefix` (e.g. one MCMS host's /** Drop every entry whose key starts with `prefix` (e.g. one MCMS host's
* dataset). Returns how many were removed. */ * dataset). Returns how many were removed. */
invalidate(prefix) { invalidate(prefix) {

View file

@ -331,7 +331,18 @@ const handlers = {
: null; : null;
let controllerCount = null; let controllerCount = null;
try { controllerCount = (await cachedBulk(s, 'controllerConfigs', () => c.listAllControllerConfigs(), fresh)).length; } catch (_) { /* best-effort */ } try { controllerCount = (await cachedBulk(s, 'controllerConfigs', () => c.listAllControllerConfigs(), fresh)).length; } catch (_) { /* best-effort */ }
return { ok: true, data: summarizeDashboard({ olts, onuStates, controllerCount }) }; const data = summarizeDashboard({ olts, onuStates, controllerCount });
// Tell the client how stale the cached inputs are. Use the OLDEST dataset
// feeding this view so the indicator never understates staleness.
const ages = [cache.ageOf(ckey(s, 'oltStates'))];
if (extraStats) ages.push(cache.ageOf(ckey(s, 'onuStates')));
const known = ages.filter((a) => a !== null);
data.cache = {
enabled: cache.enabled(),
ttlMs: CACHE_TTL_MS,
ageMs: known.length ? Math.max(...known) : 0,
};
return { ok: true, data };
}, },
async listOlts(s, body) { async listOlts(s, body) {