mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 09:58:58 +00:00
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:
co-authored by
openclaw-bot
mc-bot
parent
f538420ff1
commit
a7ad2be142
@@ -126,6 +126,7 @@ jobs:
|
|||||||
node test-issue-1418-polish-review.js
|
node test-issue-1418-polish-review.js
|
||||||
node test-issue-1420-tile-providers.js
|
node test-issue-1420-tile-providers.js
|
||||||
node test-issue-1438-marker-css-vars.js
|
node test-issue-1438-marker-css-vars.js
|
||||||
|
node test-issue-1562-observers-summary.js
|
||||||
node test-live.js
|
node test-live.js
|
||||||
node test-xss-escape-sinks.js
|
node test-xss-escape-sinks.js
|
||||||
node test-preflight-xss-gate.js
|
node test-preflight-xss-gate.js
|
||||||
|
|||||||
+107
-13
@@ -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 () {
|
(function () {
|
||||||
let observers = [];
|
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 obsSkewMap = {}; // observerID → {offsetSec, samples}
|
||||||
let wsHandler = null;
|
let wsHandler = null;
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
@@ -55,7 +134,7 @@ window.ObserversNaiveChip = {
|
|||||||
// Event delegation for data-action buttons
|
// Event delegation for data-action buttons
|
||||||
app.addEventListener('click', function (e) {
|
app.addEventListener('click', function (e) {
|
||||||
var btn = e.target.closest('[data-action]');
|
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"]');
|
var row = e.target.closest('tr[data-action="navigate"]');
|
||||||
if (row) {
|
if (row) {
|
||||||
// #1056 AC#4: at narrow widths, open detail in slide-over instead of
|
// #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
|
// #209 — Keyboard accessibility for observer rows
|
||||||
app.addEventListener('keydown', function (e) {
|
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"]');
|
var row = e.target.closest('tr[data-action="navigate"]');
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||||
@@ -98,19 +185,28 @@ window.ObserversNaiveChip = {
|
|||||||
obsSkewMap = {};
|
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 {
|
try {
|
||||||
const [data, skewData] = await Promise.all([
|
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 []; })
|
api('/observers/clock-skew', { ttl: 30000 }).catch(function() { return []; })
|
||||||
]);
|
]);
|
||||||
|
if (myId !== _loadObserversReqId) return; // stale resolve, newer in-flight
|
||||||
observers = data.observers || [];
|
observers = data.observers || [];
|
||||||
|
_fetchedAt = Date.now(); // #1562: stamp freshness for the header label
|
||||||
obsSkewMap = {};
|
obsSkewMap = {};
|
||||||
(Array.isArray(skewData) ? skewData : []).forEach(function(s) {
|
(Array.isArray(skewData) ? skewData : []).forEach(function(s) {
|
||||||
if (s && s.observerID) obsSkewMap[s.observerID] = s;
|
if (s && s.observerID) obsSkewMap[s.observerID] = s;
|
||||||
});
|
});
|
||||||
render();
|
render();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (myId !== _loadObserversReqId) return; // discard stale error too
|
||||||
document.getElementById('obsContent').innerHTML =
|
document.getElementById('obsContent').innerHTML =
|
||||||
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Error loading observers: ${e.message}</div>`;
|
`<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' };
|
return { cls: 'health-red', label: 'Offline' };
|
||||||
}
|
}
|
||||||
// Issue #1552 — exposed for tests and external callers.
|
// 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;
|
window.observerHealthStatus = healthStatus;
|
||||||
|
|
||||||
function packetBadge(o) {
|
function packetBadge(o) {
|
||||||
@@ -180,18 +279,13 @@ window.ObserversNaiveChip = {
|
|||||||
|
|
||||||
const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0));
|
const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0));
|
||||||
|
|
||||||
// Summary counts
|
// #1562 — Aggregate counts + "Last updated" freshness label come from the
|
||||||
const online = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-green').length;
|
// pure ObserversSummary helper (unit-tested in test-issue-1562-*).
|
||||||
const stale = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length;
|
const summaryCounts = window.ObserversSummary.computeCounts(filtered);
|
||||||
const offline = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-red').length;
|
const summaryHtml = window.ObserversSummary.renderHeader(summaryCounts, _fetchedAt);
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="obs-summary">
|
${summaryHtml}
|
||||||
<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>
|
|
||||||
<div class="obs-table-scroll table-fluid-wrap"><table class="data-table obs-table" id="obsTable">
|
<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>
|
<caption class="sr-only">Observer status and statistics</caption>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
|
|||||||
@@ -1820,6 +1820,12 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
|||||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||||
.obs-summary { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
|
.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); }
|
.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 { 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-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; }
|
.health-dot.health-yellow { background: var(--status-yellow); box-shadow: 0 0 6px #eab30880; }
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user