diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 8b3ef229..f14c6aba 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -126,6 +126,7 @@ jobs:
node test-issue-1418-polish-review.js
node test-issue-1420-tile-providers.js
node test-issue-1438-marker-css-vars.js
+ node test-issue-1562-observers-summary.js
node test-live.js
node test-xss-escape-sinks.js
node test-preflight-xss-gate.js
diff --git a/public/observers.js b/public/observers.js
index 059d33f1..7c19f35f 100644
--- a/public/observers.js
+++ b/public/observers.js
@@ -31,8 +31,87 @@ window.ObserversNaiveChip = {
},
};
+// #1562 — Pure helper for computing + rendering the observers-page aggregate
+// header. Split out as a window global so it's unit-testable in a vm sandbox
+// without needing a DOM, AND so the render path has one obvious place to wire
+// the "Last updated: X ago" freshness label.
+//
+// Why this exists (per #1562): the header used to derive Online/Stale/Offline
+// counts inline from a possibly-stale cached /api/observers payload, with no
+// UI signal that the data could be stale. After #1551 added Cache-Control:
+// no-store on the server response, the in-memory client cache (api() ttl)
+// could still serve old data. The "Last updated" label makes that visible;
+// manual refresh now also bypasses the cache (bust: true).
+window.ObserversSummary = (function () {
+ // #1563 — Single source of truth: aggregate counts MUST come from the
+ // same classifier used to render the per-row dots. Previously this helper
+ // had its own hardcoded thresholds parallel to healthStatus(); operators
+ // would see "5 Online" in the header but count 12 green rows by hand
+ // (regression of #1562). We now delegate to window.observerHealthStatus
+ // (exposed below by the IIFE) and map its returned .cls to the bucket.
+ //
+ // A default fallback classifier is kept for the case where this module
+ // is loaded BEFORE observers.js wires up window.observerHealthStatus
+ // (e.g. legacy test paths). It mirrors the canonical thresholds, but
+ // production code paths always hit window.observerHealthStatus.
+ function defaultClassify(lastSeen) {
+ if (!lastSeen) return { cls: 'health-red' };
+ var ago = Date.now() - new Date(lastSeen).getTime();
+ var tolerance = 30000;
+ if (ago < 600000 + tolerance) return { cls: 'health-green' };
+ if (ago < 3600000 + tolerance) return { cls: 'health-yellow' };
+ return { cls: 'health-red' };
+ }
+
+ function computeCounts(observers) {
+ var online = 0, stale = 0, offline = 0;
+ var list = Array.isArray(observers) ? observers : [];
+ var classifier = (typeof window !== 'undefined' && typeof window.observerHealthStatus === 'function')
+ ? window.observerHealthStatus
+ : defaultClassify;
+ for (var i = 0; i < list.length; i++) {
+ var h = classifier(list[i] && list[i].last_seen) || { cls: 'health-red' };
+ if (h.cls === 'health-green') online++;
+ else if (h.cls === 'health-yellow') stale++;
+ else offline++;
+ }
+ return { online: online, stale: stale, offline: offline, total: list.length };
+ }
+
+ // Renders the obs-summary block as a string. fetchedAt is a ms epoch
+ // timestamp (or null/0 for "unknown" — first render before any successful
+ // fetch). When fetchedAt is older than 60s, the timestamp gets the
+ // obs-updated-stale class so operators see the data is going stale.
+ function renderHeader(counts, fetchedAt) {
+ var c = counts || { online: 0, stale: 0, offline: 0, total: 0 };
+ var updatedHtml = '';
+ if (fetchedAt) {
+ var ageMs = Date.now() - fetchedAt;
+ var staleCls = ageMs > 60000 ? ' obs-updated-stale' : '';
+ var iso = new Date(fetchedAt).toISOString();
+ updatedHtml = ''
+ + 'Last updated: ' + timeAgo(iso) + '';
+ }
+ return ''
+ + '
'
+ + '\u25CF ' + c.online + ' Online'
+ + '\u25B2 ' + c.stale + ' Stale'
+ + '\u2715 ' + c.offline + ' Offline'
+ + '\uD83D\uDCE1 ' + c.total + ' Total'
+ + updatedHtml
+ + '
';
+ }
+
+ return { computeCounts: computeCounts, renderHeader: renderHeader };
+})();
+
(function () {
let observers = [];
+ let _fetchedAt = 0; // #1562: ms epoch when the current `observers` payload was received
+ let _loadObserversReqId = 0; // #1563: monotonic id; resolutions older than the latest are discarded
let obsSkewMap = {}; // observerID → {offsetSec, samples}
let wsHandler = null;
let refreshTimer = null;
@@ -55,7 +134,7 @@ window.ObserversNaiveChip = {
// Event delegation for data-action buttons
app.addEventListener('click', function (e) {
var btn = e.target.closest('[data-action]');
- if (btn && btn.dataset.action === 'obs-refresh') loadObservers();
+ if (btn && btn.dataset.action === 'obs-refresh') loadObservers({ bust: true });
var row = e.target.closest('tr[data-action="navigate"]');
if (row) {
// #1056 AC#4: at narrow widths, open detail in slide-over instead of
@@ -70,6 +149,14 @@ window.ObserversNaiveChip = {
});
// #209 — Keyboard accessibility for observer rows
app.addEventListener('keydown', function (e) {
+ // #1562 — Last-updated pill (role=button) supports Enter/Space to
+ // force a fresh fetch, matching click behavior on data-action="obs-refresh".
+ var refreshBtn = e.target.closest('[data-action="obs-refresh"]');
+ if (refreshBtn && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault();
+ loadObservers({ bust: true });
+ return;
+ }
var row = e.target.closest('tr[data-action="navigate"]');
if (!row) return;
if (e.key !== 'Enter' && e.key !== ' ') return;
@@ -98,19 +185,28 @@ window.ObserversNaiveChip = {
obsSkewMap = {};
}
- async function loadObservers() {
+ async function loadObservers(opts) {
+ var bust = !!(opts && opts.bust);
+ // #1563 — in-flight guard: every call gets a monotonic id; when we
+ // resolve, if a newer call has started, drop this result silently.
+ // Prevents a slow auto-refresh from clobbering a fresh manual bust
+ // (or vice versa) with stale data + a misleading "0s ago" pill.
+ var myId = ++_loadObserversReqId;
try {
const [data, skewData] = await Promise.all([
- api('/observers', { ttl: CLIENT_TTL.observers }),
+ api('/observers', { ttl: CLIENT_TTL.observers, bust: bust }),
api('/observers/clock-skew', { ttl: 30000 }).catch(function() { return []; })
]);
+ if (myId !== _loadObserversReqId) return; // stale resolve, newer in-flight
observers = data.observers || [];
+ _fetchedAt = Date.now(); // #1562: stamp freshness for the header label
obsSkewMap = {};
(Array.isArray(skewData) ? skewData : []).forEach(function(s) {
if (s && s.observerID) obsSkewMap[s.observerID] = s;
});
render();
} catch (e) {
+ if (myId !== _loadObserversReqId) return; // discard stale error too
document.getElementById('obsContent').innerHTML =
`Error loading observers: ${e.message}
`;
}
@@ -134,6 +230,9 @@ window.ObserversNaiveChip = {
return { cls: 'health-red', label: 'Offline' };
}
// Issue #1552 — exposed for tests and external callers.
+ // #1563 — Expose for ObserversSummary so aggregate counts and per-row dots
+ // share ONE classifier (single source of truth). If anything reintroduces
+ // parallel thresholds, the new ObserversSummary regression test breaks.
window.observerHealthStatus = healthStatus;
function packetBadge(o) {
@@ -180,18 +279,13 @@ window.ObserversNaiveChip = {
const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0));
- // Summary counts
- const online = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-green').length;
- const stale = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length;
- const offline = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-red').length;
+ // #1562 — Aggregate counts + "Last updated" freshness label come from the
+ // pure ObserversSummary helper (unit-tested in test-issue-1562-*).
+ const summaryCounts = window.ObserversSummary.computeCounts(filtered);
+ const summaryHtml = window.ObserversSummary.renderHeader(summaryCounts, _fetchedAt);
el.innerHTML = `
-
- ● ${online} Online
- ▲ ${stale} Stale
- ✕ ${offline} Offline
- 📡 ${filtered.length} Total
-
+ ${summaryHtml}