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:
parent
6ee1d37a79
commit
c2206ad5a7
6 changed files with 84 additions and 5 deletions
|
|
@ -803,6 +803,11 @@ Consequences for editing:
|
|||
read-modify-write fetches and the FEC pre-flight stay **uncached** (must be
|
||||
live). Writes call `invalidate(session, [...])` for the affected datasets.
|
||||
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 +
|
||||
`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
|
||||
|
|
|
|||
|
|
@ -492,6 +492,20 @@ button.danger:hover:not(:disabled) {
|
|||
}
|
||||
.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; }
|
||||
.tile {
|
||||
background: linear-gradient(180deg, rgba(33, 200, 255, 0.07), transparent 70%), var(--bg-elev);
|
||||
|
|
|
|||
|
|
@ -1551,8 +1551,10 @@ $('#btn-olt-execute').addEventListener('click', async () => {
|
|||
|
||||
// -------- Dashboard: network telemetry --------
|
||||
|
||||
$('#dash-refresh').addEventListener('click', loadDashboard);
|
||||
$('#dash-extra').addEventListener('change', loadDashboard);
|
||||
// Refresh forces a fresh server fetch (bypasses the cache); the toggle and
|
||||
// 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.
|
||||
function fmtBps(v) {
|
||||
|
|
@ -1578,6 +1580,44 @@ function fmtDbm(n) {
|
|||
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) {
|
||||
const cls = count > 0 ? 'status-warn' : 'status-ok';
|
||||
const tappable = drillKey && count > 0;
|
||||
|
|
@ -1613,13 +1653,13 @@ function showListModal(title, items) {
|
|||
overlay.querySelector('.modal-ok').focus();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
async function loadDashboard(force) {
|
||||
const extra = $('#dash-extra').checked;
|
||||
$('#dash-status').textContent = extra
|
||||
? 'Loading network + per-ONU Rx/FEC scan… (this fetches every ONU-STATE)'
|
||||
: 'Loading network overview…';
|
||||
$('#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;
|
||||
if (!res.ok) {
|
||||
$('#dash-status').textContent = `Error: ${res.error?.message || 'unknown'}`;
|
||||
|
|
@ -1741,4 +1781,6 @@ function renderDashboard(d) {
|
|||
const stamp = d.sampledAt ? new Date(d.sampledAt).toLocaleTimeString() : '';
|
||||
$('#dash-status').textContent =
|
||||
`${d.onuTotal} ONUs · ${d.oltCount} OLTs${d.extraStats ? ' · detailed scan' : ''}${stamp ? ' · ' + stamp : ''}`;
|
||||
|
||||
renderCacheBadge(d.cache);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
<div class="fleet-head">
|
||||
<h2>Dashboard</h2>
|
||||
<div class="row">
|
||||
<span id="dash-cache" class="dash-cache" title=""></span>
|
||||
<label class="inline dash-toggle">
|
||||
<input id="dash-extra" type="checkbox" />
|
||||
Detailed stats (Rx / FEC scan)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ class TtlCache {
|
|||
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
|
||||
* dataset). Returns how many were removed. */
|
||||
invalidate(prefix) {
|
||||
|
|
|
|||
|
|
@ -331,7 +331,18 @@ const handlers = {
|
|||
: null;
|
||||
let controllerCount = null;
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue