Files
meshcore-analyzer/test-issue-1562-observers-summary.js
T
a7ad2be142 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>
2026-06-04 08:30:06 -07:00

171 lines
6.7 KiB
JavaScript

/**
* #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);