Web: stale stats indicator in Stats header

Add a blinking "refreshed >Xm ago" label aligned to the right of the
Stats page h2 header.  The label is hidden until stats are more than
1 minute old, then appears with a CSS color-pulse animation (red to
--text-muted) to draw attention. It hides immediately when a refresh
is initiated and reappears only after the next successful fetch.

- Add @keyframes blink-stale (color pulse red <-> --text-muted) and
  .stats-stale CSS class (13px, normal weight, 1.5s animation)
- Add statsLastUpdated span inside the Stats h2 (flex, right-aligned,
  initially hidden)
- statsLastFetchedAt: null until first successful fetch, reset to null
  at the start of each loadStatsPage call, set to Date.now() on success
- updateStatsLastUpdated() hides span when null or secs < 60, shows
  minutes/hours elapsed otherwise
- setInterval(updateStatsLastUpdated, 10000) runs only on /stats page
This commit is contained in:
Valentin V. Bartenev
2026-05-09 21:23:40 +03:00
parent 55f21997aa
commit bbb07b65ca
+24 -1
View File
@@ -481,6 +481,8 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
.stats-shell { display:grid; gap:12px; }
.stats-empty, .stats-error { background:var(--surface2); border:1px dashed var(--border); border-radius:12px; padding:16px; color:var(--text-muted); }
.stats-error { color:var(--status-red); }
@keyframes blink-stale { 0%, 100% { color: #e05050; } 50% { color: var(--text-muted); } }
.stats-stale { color: #e05050; font-size: 13px; font-weight: 400; animation: blink-stale 1.5s ease-in-out infinite; }
.trend-section-copy { display:grid; gap:6px; }
.trend-section-copy p { margin:0; color:var(--text-muted); }
.trend-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
@@ -983,7 +985,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
</section>
<section class="card" id="statsPagePanel" style="display:none">
<h2>Stats</h2>
<h2 style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0">Stats<span id="statsLastUpdated" class="stats-stale" style="display:none"></span></h2>
<div id="statsSummary" class="stats-shell">
<div class="stats-empty">Loading summary...</div>
</div>
@@ -1017,6 +1019,7 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
let commandQueue = Promise.resolve();
let radioPresetEntries = [];
let currentRadioConfig = null;
let statsLastFetchedAt = null;
const statusEl = document.getElementById("status");
const replyEl = document.getElementById("reply");
const themeToggleEl = document.getElementById("themeToggle");
@@ -2092,6 +2095,20 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
}
return order;
}
function updateStatsLastUpdated() {
const el = document.getElementById("statsLastUpdated");
if (!statsLastFetchedAt) { el.style.display = "none"; return; }
const secs = Math.round((Date.now() - statsLastFetchedAt) / 1000);
if (secs < 60) { return; }
let label;
if (secs < 3600) {
label = Math.floor(secs / 60) + "m ago";
} else {
label = Math.floor(secs / 3600) + "h " + Math.floor((secs % 3600) / 60) + "m ago";
}
el.textContent = "refreshed >" + label;
el.style.display = "";
}
function initTrendCards(seriesOrder) {
const trendsEl = document.getElementById("statsTrends");
if (!trendsEl) return;
@@ -2403,11 +2420,14 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
async function loadStatsPage() {
const summaryEl = document.getElementById("statsSummary");
if (!summaryEl) return;
statsLastFetchedAt = null;
updateStatsLastUpdated();
summaryEl.innerHTML = '<div class="stats-empty">Loading summary...</div>';
let summaryPayload = null;
try {
summaryPayload = await fetchJson("/api/stats?view=summary");
if (!summaryPayload) throw new Error("no summary payload");
statsLastFetchedAt = Date.now();
renderStatsSummary(summaryPayload);
initTrendCards(getTrendSeriesOrder(summaryPayload));
} catch (error) {
@@ -2679,6 +2699,9 @@ const char kWebPanelAppHtml[] PROGMEM = R"HTML(
refreshMeshcoretelModeUi();
refreshLetsmeshModeUi();
initApp();
if (isStatsPage) {
setInterval(updateStatsLastUpdated, 10000);
}
</script>
</body>
</html>