From c2206ad5a7cc7152c9ac47b61a38010b4c3b0542 Mon Sep 17 00:00:00 2001 From: Jon Vanvik Date: Tue, 23 Jun 2026 14:25:46 +0200 Subject: [PATCH] dashboard: live cache-age indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 5 +++++ renderer/app.css | 14 +++++++++++++ renderer/app.js | 50 +++++++++++++++++++++++++++++++++++++++++---- renderer/index.html | 1 + web/cache.js | 6 ++++++ web/server.js | 13 +++++++++++- 6 files changed, 84 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb4250f..2f72356 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/renderer/app.css b/renderer/app.css index 9687926..6c4eff7 100644 --- a/renderer/app.css +++ b/renderer/app.css @@ -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); diff --git a/renderer/app.js b/renderer/app.js index de18db0..1b3939b 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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); } diff --git a/renderer/index.html b/renderer/index.html index b1c09ab..175354b 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -49,6 +49,7 @@

Dashboard

+