fix(observers): show "Last updated" timestamp on aggregate header (closes #1562) (#1563)

Closes #1562. Follow-up to #1551 and #1552.

## Problem

On CDN-fronted deployments (e.g. meshcore.meshat.se), the observers page
header rendered totals computed entirely client-side from a
possibly-stale `/api/observers` response. Operators saw e.g. `0 Online /
43 Stale / 37 Offline` while a cache-busted request returned `44 Online
/ 0 Stale / 36 Offline` — the aggregate row was the first thing they
looked at to assess mesh health, so wrong numbers meant wrong actions.

#1551 added `Cache-Control: no-store` on `/api/*` responses, but the
client also has its own in-memory cache (`api(path, { ttl })`), and
there was no UI signal at all that the rendered counts could be stale.

## Fix scope (Option 3 + light Option 2)

Per the issue's three options, this PR implements **Option 3**
(timestamp label) and a light **Option 2** (manual-refresh button
bypasses client cache). Option 1 (a new server-side
`/api/observers/summary` endpoint) is **deferred** as a follow-up — it's
the most correct fix, but a bigger lift than what's needed to stop
operators from acting on silently-wrong numbers.

## Changes

- **`public/observers.js`**
- New `window.ObserversSummary` pure helper exposing
`computeCounts(observers)` and `renderHeader(counts, fetchedAt)`. Pure
functions = easy to unit test.
- Track `_fetchedAt` (ms) on each successful `loadObservers()` response.
- `render()` delegates header HTML to
`ObserversSummary.renderHeader(counts, fetchedAt)`. Existing aggregate
display (`Online / Stale / Offline / Total`) is preserved exactly — the
only visible additions are the "Last updated: Xs ago" label and a
warning class when the timestamp is >60s old.
- Manual refresh button now passes `{ bust: true }` to `api()` so the
operator can force a fresh fetch when they suspect staleness.
- **`public/style.css`**
- New `.obs-updated` and `.obs-updated-stale` rules using existing
`--text-muted` / `--warning` CSS variables (no new colors).
- **`test-issue-1562-observers-summary.js`** +
**`.github/workflows/deploy.yml`**
- Unit tests for `computeCounts` (mixed ages → 1/1/1 + total),
`renderHeader` (label presence + stale-warning class), plus DOM-grep
checks that observers.js still tracks `_fetchedAt` and bypasses the
cache on manual refresh.

## TDD

Red commit asserts `ObserversSummary` doesn't exist / no `_fetchedAt`
tracking / no `obs-updated-stale` CSS → fails. Green commit adds the
implementation → passes.

## What this PR does NOT touch

- **Observer health thresholds** — owned by #1552, untouched here.
- **`healthStatus()` per-row classification** — untouched. The same
function still gates per-row colors AND aggregate counts; the fix is
about freshness visibility, not classification logic.
- **No new server endpoint** — Option 1 deferred. Will file a follow-up
if anyone wants that tracked.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: mc-bot <bot@meshcore.local>
This commit is contained in:
Kpa-clawbot
2026-06-04 08:30:06 -07:00
committed by GitHub
parent f538420ff1
commit a7ad2be142
5 changed files with 560 additions and 13 deletions
+1
View File
@@ -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
+107 -13
View File
@@ -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 = '<span class="obs-stat obs-updated' + staleCls
+ '" data-action="obs-refresh" role="button" tabindex="0"'
+ ' title="Click to force a fresh fetch (bypass client cache)"'
+ ' aria-label="Last updated ' + timeAgo(iso) + ' — click to refresh">'
+ 'Last updated: ' + timeAgo(iso) + '</span>';
}
return ''
+ '<div class="obs-summary">'
+ '<span class="obs-stat"><span class="health-dot health-green">\u25CF</span> ' + c.online + ' Online</span>'
+ '<span class="obs-stat"><span class="health-dot health-yellow">\u25B2</span> ' + c.stale + ' Stale</span>'
+ '<span class="obs-stat"><span class="health-dot health-red">\u2715</span> ' + c.offline + ' Offline</span>'
+ '<span class="obs-stat">\uD83D\uDCE1 ' + c.total + ' Total</span>'
+ updatedHtml
+ '</div>';
}
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 =
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Error loading observers: ${e.message}</div>`;
}
@@ -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 = `
<div class="obs-summary">
<span class="obs-stat"><span class="health-dot health-green">●</span> ${online} Online</span>
<span class="obs-stat"><span class="health-dot health-yellow">▲</span> ${stale} Stale</span>
<span class="obs-stat"><span class="health-dot health-red">✕</span> ${offline} Offline</span>
<span class="obs-stat">📡 ${filtered.length} Total</span>
</div>
${summaryHtml}
<div class="obs-table-scroll table-fluid-wrap"><table class="data-table obs-table" id="obsTable">
<caption class="sr-only">Observer status and statistics</caption>
<thead><tr>
+6
View File
@@ -1820,6 +1820,12 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; }
.obs-summary { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
.obs-stat { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-muted); }
/* #1562 — "Last updated: X ago" pill on the observers-page header.
Clickable (forces a fresh fetch / bypasses client cache). Goes to
--warning when the cached payload is >60s old so operators see staleness. */
.obs-updated { cursor: pointer; font-size: 13px; color: var(--text-muted); border: 1px dashed transparent; padding: 2px 6px; border-radius: 4px; }
.obs-updated:hover, .obs-updated:focus { border-color: var(--border); outline: none; }
.obs-updated-stale { color: var(--status-yellow); border-color: var(--status-yellow); }
.health-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.health-dot.health-green { background: var(--status-green); box-shadow: 0 0 6px #22c55e80; }
.health-dot.health-yellow { background: var(--status-yellow); box-shadow: 0 0 6px #eab30880; }
+170
View File
@@ -0,0 +1,170 @@
/**
* #1562 Observers page header: "Last updated: X ago" label and
* compute aggregate counts from a pure, testable helper so operators
* can see when the cached payload is stale.
*
* Pattern: pure helper on window.ObserversSummary (so it's easy to test
* without a DOM) + render-string assertions for the header HTML.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const assert = require('assert');
let passed = 0, failed = 0;
function t(name, fn) {
try { fn(); passed++; console.log(' ✓ ' + name); }
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
}
function makeSandbox() {
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {} },
document: {
readyState: 'complete',
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
},
console,
Date, Math, Array, Object, Number, String, Boolean, RegExp, JSON,
Promise, Map, Set, Symbol, Error,
setTimeout, clearTimeout, setInterval, clearInterval,
performance: { now: () => Date.now() },
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
location: { hash: '#/observers', search: '' },
history: { pushState: () => {} },
navigator: { userAgent: 'node' },
requestAnimationFrame: (cb) => setTimeout(cb, 0),
URL,
URLSearchParams,
};
ctx.window.location = ctx.location;
ctx.window.localStorage = ctx.localStorage;
vm.createContext(ctx);
return ctx;
}
function load(ctx, file) {
vm.runInContext(fs.readFileSync(path.join(__dirname, file), 'utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
const ctx = makeSandbox();
load(ctx, 'public/roles.js');
load(ctx, 'public/app.js');
// Stub IIFE-required globals so observers.js loads cleanly in sandbox
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, offChange: () => {}, getSelected: () => null };
ctx.registerPage = () => {};
ctx.debouncedOnWS = () => () => {};
ctx.offWS = () => {};
ctx.CLIENT_TTL = { observers: 120000 };
ctx.api = () => Promise.resolve({ observers: [] });
ctx.makeColumnsResizable = () => {};
ctx.TableResponsive = { register: () => {} };
ctx.SlideOver = null;
ctx.observerSkewSeverity = () => 'ok';
ctx.renderSkewBadge = () => '';
load(ctx, 'public/observers.js');
const Summary = ctx.window.ObserversSummary;
console.log('\n=== #1562 ObserversSummary helper ===');
t('window.ObserversSummary is exposed', () => {
assert.ok(Summary, 'expected window.ObserversSummary to be defined');
assert.strictEqual(typeof Summary.computeCounts, 'function');
assert.strictEqual(typeof Summary.renderHeader, 'function');
});
t('computeCounts classifies 1 online (15s) / 1 stale (90min) / 1 offline (25h)', () => {
const now = Date.now();
const obs = [
{ id: 'a', last_seen: new Date(now - 15 * 1000).toISOString() },
{ id: 'b', last_seen: new Date(now - 90 * 60 * 1000).toISOString() },
{ id: 'c', last_seen: new Date(now - 25 * 60 * 60 * 1000).toISOString() },
];
const r = Summary.computeCounts(obs);
assert.strictEqual(r.online, 1, 'online=' + r.online);
assert.strictEqual(r.stale, 1, 'stale=' + r.stale);
assert.strictEqual(r.offline, 1, 'offline=' + r.offline);
assert.strictEqual(r.total, 3, 'total=' + r.total);
});
t('computeCounts handles empty + null last_seen as offline', () => {
const r = Summary.computeCounts([{ id: 'x', last_seen: null }]);
assert.strictEqual(r.offline, 1);
assert.strictEqual(r.online, 0);
assert.strictEqual(r.stale, 0);
});
t('renderHeader includes "Last updated" + relative-time text', () => {
const fetchedAt = Date.now() - 10 * 1000;
const html = Summary.renderHeader({ online: 1, stale: 0, offline: 0, total: 1 }, fetchedAt);
assert.ok(/Last updated/i.test(html), 'should mention "Last updated": ' + html);
assert.ok(/ago/.test(html), 'should include relative "ago": ' + html);
});
t('renderHeader includes count labels (Online / Stale / Offline)', () => {
const html = Summary.renderHeader({ online: 5, stale: 2, offline: 3, total: 10 }, Date.now());
assert.ok(/5\s*Online/.test(html), 'online count: ' + html);
assert.ok(/2\s*Stale/.test(html), 'stale count: ' + html);
assert.ok(/3\s*Offline/.test(html), 'offline count: ' + html);
});
t('renderHeader marks obs-updated-stale class when fetchedAt > 60s old', () => {
const fetchedAt = Date.now() - 90 * 1000;
const html = Summary.renderHeader({ online: 0, stale: 0, offline: 0, total: 0 }, fetchedAt);
assert.ok(/obs-updated-stale/.test(html), 'expected obs-updated-stale class: ' + html);
});
t('renderHeader omits stale-warning class when fetchedAt < 60s old', () => {
const fetchedAt = Date.now() - 10 * 1000;
const html = Summary.renderHeader({ online: 0, stale: 0, offline: 0, total: 0 }, fetchedAt);
assert.ok(!/obs-updated-stale/.test(html), 'should NOT mark stale: ' + html);
});
t('renderHeader still renders cleanly when fetchedAt is null/0 (graceful degrade)', () => {
const html = Summary.renderHeader({ online: 0, stale: 0, offline: 0, total: 0 }, null);
assert.ok(typeof html === 'string' && html.length > 0, 'returns a non-empty string');
});
console.log('\n=== #1562 DOM-grep checks ===');
const observersSrc = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
t('observers.js exposes ObserversSummary global', () => {
assert.ok(/window\.ObserversSummary\s*=/.test(observersSrc),
'expected `window.ObserversSummary =` assignment in observers.js');
});
t('observers.js tracks a fetchedAt timestamp', () => {
assert.ok(/_fetchedAt|fetchedAt/.test(observersSrc),
'expected fetchedAt tracking');
});
t('observers.js calls Summary.renderHeader (or equivalent) — not the old inline block', () => {
assert.ok(/ObserversSummary\.renderHeader|Summary\.renderHeader/.test(observersSrc),
'render() should delegate header HTML to ObserversSummary.renderHeader');
});
t('observers.js bypasses cache on manual refresh (bust: true)', () => {
assert.ok(/bust\s*:\s*true/.test(observersSrc),
'expected api(..., { bust: true }) on the manual refresh path');
});
const styleSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
t('style.css defines .obs-updated-stale visual rule', () => {
assert.ok(/\.obs-updated-stale\b/.test(styleSrc),
'expected .obs-updated-stale class in style.css');
});
console.log('\n' + '='.repeat(40));
console.log(' #1562 ObserversSummary: ' + passed + ' passed, ' + failed + ' failed');
console.log('='.repeat(40));
if (failed > 0) process.exit(1);
+276
View File
@@ -0,0 +1,276 @@
/**
* #1563 Round-1 review must-fix tests:
*
* A. ObserversSummary aggregate counts MUST come from the SAME
* classifier used to render per-row dots (window.observerHealthStatus),
* not a parallel hardcoded threshold ladder. Regression pin for #1562:
* if anyone re-introduces hardcoded thresholds in the summary, this
* test breaks because the per-row tally and the aggregate counts
* disagree.
*
* B. loadObservers() must guard against in-flight races: if a slow call
* resolves AFTER a newer call, the newer call's data wins (and the
* "Last updated" pill reflects the latest fetch, not the stale one).
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const assert = require('assert');
let passed = 0, failed = 0;
function t(name, fn) {
try {
const r = fn();
if (r && typeof r.then === 'function') {
return r.then(
() => { passed++; console.log(' ✓ ' + name); },
(e) => { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
);
}
passed++; console.log(' ✓ ' + name);
} catch (e) {
failed++; console.error(' ✗ ' + name + ': ' + e.message);
}
}
function makeSandbox() {
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {} },
document: {
readyState: 'complete',
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
head: { appendChild: () => {} },
getElementById: () => ({ innerHTML: '' }),
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
},
console,
Date, Math, Array, Object, Number, String, Boolean, RegExp, JSON,
Promise, Map, Set, Symbol, Error,
setTimeout, clearTimeout, setInterval, clearInterval,
performance: { now: () => Date.now() },
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
location: { hash: '#/observers', search: '' },
history: { pushState: () => {} },
navigator: { userAgent: 'node' },
requestAnimationFrame: (cb) => setTimeout(cb, 0),
URL,
URLSearchParams,
};
ctx.window.location = ctx.location;
ctx.window.localStorage = ctx.localStorage;
vm.createContext(ctx);
return ctx;
}
function load(ctx, file) {
vm.runInContext(fs.readFileSync(path.join(__dirname, file), 'utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
console.log('\n=== #1563 A. Aggregate uses same classifier as per-row dots ===');
(function runAggregateTests() {
const ctx = makeSandbox();
load(ctx, 'public/roles.js');
load(ctx, 'public/app.js');
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, offChange: () => {}, getSelected: () => null };
ctx.registerPage = () => {};
ctx.debouncedOnWS = () => () => {};
ctx.offWS = () => {};
ctx.CLIENT_TTL = { observers: 120000 };
ctx.api = () => Promise.resolve({ observers: [] });
ctx.makeColumnsResizable = () => {};
ctx.TableResponsive = { register: () => {} };
ctx.SlideOver = null;
ctx.observerSkewSeverity = () => 'ok';
ctx.renderSkewBadge = () => '';
load(ctx, 'public/observers.js');
const Summary = ctx.window.ObserversSummary;
const healthStatus = ctx.window.observerHealthStatus;
t('window.observerHealthStatus is exposed by observers.js', () => {
assert.strictEqual(typeof healthStatus, 'function',
'observers.js must expose window.observerHealthStatus so ObserversSummary can call it');
});
t('REGRESSION PIN: aggregate counts equal per-row tally for 10 mixed observers', () => {
const now = Date.now();
const obs = [
{ id: '1', last_seen: new Date(now - 5 * 1000).toISOString() }, // green
{ id: '2', last_seen: new Date(now - 60 * 1000).toISOString() }, // green
{ id: '3', last_seen: new Date(now - 4 * 60 * 1000).toISOString() }, // green
{ id: '4', last_seen: new Date(now - 9 * 60 * 1000).toISOString() }, // green
{ id: '5', last_seen: new Date(now - 15 * 60 * 1000).toISOString() }, // yellow
{ id: '6', last_seen: new Date(now - 30 * 60 * 1000).toISOString() }, // yellow
{ id: '7', last_seen: new Date(now - 59 * 60 * 1000).toISOString() }, // yellow
{ id: '8', last_seen: new Date(now - 2 * 3600 * 1000).toISOString() }, // red
{ id: '9', last_seen: new Date(now - 24 * 3600 * 1000).toISOString() },// red
{ id: '10', last_seen: null }, // red
];
// Per-row tally (same classifier used by the table renderer)
let rowGreen = 0, rowYellow = 0, rowRed = 0;
for (const o of obs) {
const h = healthStatus(o.last_seen);
if (h.cls === 'health-green') rowGreen++;
else if (h.cls === 'health-yellow') rowYellow++;
else rowRed++;
}
const agg = Summary.computeCounts(obs);
assert.strictEqual(agg.online, rowGreen, 'online ' + agg.online + ' != per-row green ' + rowGreen);
assert.strictEqual(agg.stale, rowYellow, 'stale ' + agg.stale + ' != per-row yellow ' + rowYellow);
assert.strictEqual(agg.offline, rowRed, 'offline '+ agg.offline + ' != per-row red ' + rowRed);
assert.strictEqual(agg.total, obs.length);
});
t('source: observers.js no longer contains the old standalone `classify()` ladder', () => {
// Defense in depth — make sure the parallel ladder isn't re-introduced.
const src = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
// Old code had `function classify(lastSeen)` returning string 'online'/'stale'/'offline'
// The new defaultClassify() returns { cls: 'health-*' } objects.
assert.ok(!/function\s+classify\s*\(\s*lastSeen\s*\)\s*\{[\s\S]*?return\s+'online'/.test(src),
'observers.js still has the legacy `classify()` returning string buckets — must be removed');
});
})();
console.log('\n=== #1563 B. loadObservers in-flight guard ===');
(async function runInflightTests() {
const ctx = makeSandbox();
load(ctx, 'public/roles.js');
load(ctx, 'public/app.js');
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, offChange: () => {}, getSelected: () => null };
ctx.registerPage = (name, page) => { ctx.__page = page; };
ctx.debouncedOnWS = () => () => {};
ctx.offWS = () => {};
ctx.CLIENT_TTL = { observers: 120000 };
ctx.makeColumnsResizable = () => {};
ctx.TableResponsive = { register: () => {} };
ctx.SlideOver = null;
ctx.observerSkewSeverity = () => 'ok';
ctx.renderSkewBadge = () => '';
// Controllable api: returns observers["fast"] or observers["slow"]
// with deferred resolution per URL. We capture the resolvers so we
// can control ordering.
const deferred = {};
let callIndex = 0;
ctx.api = function (url) {
callIndex++;
if (url === '/observers') {
let payload;
if (callIndex === 1) payload = 'slow';
else if (callIndex === 3) payload = 'fast'; // 3rd api call = 2nd observers fetch
else payload = 'other';
return new Promise((resolve) => {
deferred[payload] = () => resolve({ observers: [{ id: payload, last_seen: new Date().toISOString() }] });
});
}
// /observers/clock-skew etc.
return Promise.resolve([]);
};
load(ctx, 'public/observers.js');
// We can't easily call the IIFE-private loadObservers directly. Instead,
// invoke it via the registered page's init() — which calls loadObservers()
// on mount and again via the refresh button. Since init() is heavy, we
// assert behavior through observable side effects: window.ObserversSummary
// helper + the visible #obsContent innerHTML. But cleaner: drive the
// IIFE through the exposed test seam — we expose loadObservers via a
// window seam in observers.js. If it doesn't exist, skip the runtime
// test but the source-grep test below still asserts the guard exists.
await t('source: loadObservers tracks a monotonic request id', () => {
const src = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
assert.ok(/_loadObserversReqId|loadObserversReqId/.test(src),
'observers.js must track a monotonic request id on loadObservers');
assert.ok(/myId\s*!==\s*_loadObserversReqId/.test(src) || /myId\s*!==\s*loadObserversReqId/.test(src),
'observers.js must compare per-call id against the latest before applying data');
});
await t('source: stale resolutions return early before assigning observers/_fetchedAt', () => {
const src = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
// The guard must appear BEFORE `observers = data.observers` to actually drop stale data.
const guardIdx = src.search(/if\s*\(\s*myId\s*!==\s*_loadObserversReqId\s*\)\s*return/);
const assignIdx = src.search(/observers\s*=\s*data\.observers/);
assert.ok(guardIdx > -1 && assignIdx > -1, 'both guard and assignment must exist');
assert.ok(guardIdx < assignIdx, 'guard must come BEFORE observers assignment, else stale data still lands');
});
// Runtime race test: fire two calls back-to-back, resolve slow LAST,
// assert _fetchedAt + observers reflect the SECOND (fast) call's data,
// not the late-resolving first call.
await t('runtime: 2nd loadObservers wins even when 1st resolves later', async () => {
// We need a handle on loadObservers. Re-instantiate a fresh sandbox with
// a tiny seam injected via post-load eval.
const ctx2 = makeSandbox();
load(ctx2, 'public/roles.js');
load(ctx2, 'public/app.js');
ctx2.RegionFilter = { init: () => {}, onChange: () => () => {}, offChange: () => {}, getSelected: () => null };
ctx2.registerPage = () => {};
ctx2.debouncedOnWS = () => () => {};
ctx2.offWS = () => {};
ctx2.CLIENT_TTL = { observers: 120000 };
ctx2.makeColumnsResizable = () => {};
ctx2.TableResponsive = { register: () => {} };
ctx2.SlideOver = null;
ctx2.observerSkewSeverity = () => 'ok';
ctx2.renderSkewBadge = () => '';
const handles = { slow: null, fast: null };
let n = 0;
ctx2.api = function (url) {
if (url === '/observers') {
n++;
return new Promise((resolve) => {
if (n === 1) handles.slow = () => resolve({ observers: [{ id: 'SLOW', last_seen: new Date().toISOString() }] });
else if (n === 2) handles.fast = () => resolve({ observers: [{ id: 'FAST', last_seen: new Date().toISOString() }] });
else resolve({ observers: [] });
});
}
return Promise.resolve([]);
};
// Inject a seam: re-exec observers.js with a trailing line that
// exposes loadObservers + the `observers` local + `_fetchedAt` for
// inspection. We patch the source on the fly.
const src = fs.readFileSync(path.join(__dirname, 'public', 'observers.js'), 'utf8');
// Append exposure inside the IIFE by replacing the closing `})();` of
// the second IIFE with seam code then re-closing.
const seam =
"\n window.__test_loadObservers = loadObservers;\n" +
" window.__test_getState = function () { return { observers: observers, fetchedAt: _fetchedAt }; };\n" +
"})();\n";
const lastClose = src.lastIndexOf('})();');
const patched = src.slice(0, lastClose) + seam;
vm.runInContext(patched, ctx2);
const load1 = ctx2.window.__test_loadObservers();
const load2 = ctx2.window.__test_loadObservers();
// Resolve in reverse order: fast (2nd call) first, then slow (1st call).
handles.fast();
await load2;
const afterFast = ctx2.window.__test_getState();
assert.strictEqual(afterFast.observers[0].id, 'FAST',
'after 2nd call resolves, observers should be FAST, got ' + JSON.stringify(afterFast.observers));
const fetchedAtAfterFast = afterFast.fetchedAt;
handles.slow();
await load1;
const afterSlow = ctx2.window.__test_getState();
assert.strictEqual(afterSlow.observers[0].id, 'FAST',
'stale SLOW resolve must NOT clobber FAST data, got ' + JSON.stringify(afterSlow.observers));
assert.strictEqual(afterSlow.fetchedAt, fetchedAtAfterFast,
'_fetchedAt must NOT be updated by stale SLOW resolve (would mislead the "Last updated" pill)');
});
})().then(() => {
console.log('\n' + '='.repeat(40));
console.log(' #1563 round-1: ' + passed + ' passed, ' + failed + ' failed');
console.log('='.repeat(40));
if (failed > 0) process.exit(1);
});