mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 00:38:18 +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>
358 lines
18 KiB
JavaScript
358 lines
18 KiB
JavaScript
/* === CoreScope — observers.js === */
|
|
'use strict';
|
|
|
|
// Issue #1478 — naive-clock ⚠️ chip.
|
|
// Exposed as a window global so the renderer (and a jsdom-style test) can
|
|
// call it without depending on the IIFE-scoped helpers below. Returns the
|
|
// chip HTML when the observer's clock is currently flagged naive, or "" when
|
|
// it's clean — never throws.
|
|
window.ObserversNaiveChip = {
|
|
render: function (o) {
|
|
if (!o || o.clock_naive !== true) return '';
|
|
var sec = Number(o.clock_skew_seconds || 0);
|
|
var absSec = Math.abs(sec);
|
|
var magnitude;
|
|
if (absSec >= 3600) {
|
|
magnitude = (absSec / 3600).toFixed(absSec >= 36000 ? 0 : 1) + 'h';
|
|
} else if (absSec >= 60) {
|
|
magnitude = Math.round(absSec / 60) + 'm';
|
|
} else {
|
|
magnitude = absSec + 's';
|
|
}
|
|
var dir = sec < 0 ? 'behind' : 'ahead of';
|
|
var count = Number(o.clock_skew_count_24h || 0);
|
|
var tip = 'Observer\u2019s local clock is ' + magnitude + ' ' + dir
|
|
+ ' UTC — per-packet timing for this observer is being clamped to ingest time'
|
|
+ (count > 0 ? ' (' + count + ' events in last 24h)' : '')
|
|
+ '. Fix: set the host clock to UTC, or emit Z-suffixed/offset-aware timestamps.';
|
|
return '<span class="obs-clock-naive-chip" title="' + tip.replace(/"/g, '"')
|
|
+ '" aria-label="' + tip.replace(/"/g, '"')
|
|
+ '" style="margin-left:6px;cursor:help">\u26A0\uFE0F</span>';
|
|
},
|
|
};
|
|
|
|
// #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;
|
|
let regionChangeHandler = null;
|
|
|
|
function init(app) {
|
|
app.innerHTML = `
|
|
<div class="observers-page">
|
|
<div class="page-header">
|
|
<h2>Observer Status</h2>
|
|
<a href="#/compare" class="btn-icon" title="Compare observers" aria-label="Compare observers" style="text-decoration:none">🔍</a>
|
|
<button class="btn-icon" data-action="obs-refresh" title="Refresh" aria-label="Refresh observers">🔄</button>
|
|
</div>
|
|
<div id="obsRegionFilter" class="region-filter-container"></div>
|
|
<div id="obsContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>
|
|
</div>`;
|
|
RegionFilter.init(document.getElementById('obsRegionFilter'));
|
|
regionChangeHandler = RegionFilter.onChange(function () { render(); });
|
|
loadObservers();
|
|
// 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({ 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
|
|
// navigating to a separate page.
|
|
if (window.SlideOver && window.SlideOver.shouldUse()) {
|
|
e.preventDefault();
|
|
openObserverSlideOver(row.dataset.value);
|
|
return;
|
|
}
|
|
location.hash = row.dataset.value;
|
|
}
|
|
});
|
|
// #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;
|
|
e.preventDefault();
|
|
if (window.SlideOver && window.SlideOver.shouldUse()) {
|
|
openObserverSlideOver(row.dataset.value);
|
|
return;
|
|
}
|
|
location.hash = row.dataset.value;
|
|
});
|
|
// Auto-refresh every 30s
|
|
refreshTimer = setInterval(loadObservers, 30000);
|
|
wsHandler = debouncedOnWS(function (msgs) {
|
|
if (msgs.some(function (m) { return m.type === 'packet'; })) loadObservers();
|
|
});
|
|
}
|
|
|
|
function destroy() {
|
|
if (wsHandler) offWS(wsHandler);
|
|
wsHandler = null;
|
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
refreshTimer = null;
|
|
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
|
|
regionChangeHandler = null;
|
|
observers = [];
|
|
obsSkewMap = {};
|
|
}
|
|
|
|
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, 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>`;
|
|
}
|
|
}
|
|
|
|
// NOTE: Comparing server timestamps to Date.now() can skew if client/server
|
|
// clocks differ. We add ±30s tolerance to thresholds to reduce false positives.
|
|
function healthStatus(lastSeen) {
|
|
if (!lastSeen) return { cls: 'health-red', label: 'Unknown' };
|
|
const ago = Date.now() - new Date(lastSeen).getTime();
|
|
const tolerance = 30000; // 30s tolerance for clock skew
|
|
// Issue #1552 — thresholds are operator-configurable via config.json
|
|
// healthThresholds.observerOnlineMinutes / observerStaleMinutes, surfaced
|
|
// to the client through window.HEALTH_THRESHOLDS. Defaults are 60 min
|
|
// Online / 1440 min (24h) Stale, matching node thresholds (#1552).
|
|
const th = (typeof window !== 'undefined' && window.HEALTH_THRESHOLDS) || {};
|
|
const onlineMs = th.observerOnlineMs || 3600000;
|
|
const staleMs = th.observerStaleMs || 86400000;
|
|
if (ago < onlineMs + tolerance) return { cls: 'health-green', label: 'Online' };
|
|
if (ago < staleMs + tolerance) return { cls: 'health-yellow', label: 'Stale' };
|
|
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) {
|
|
if (!o.last_packet_at) return '<span title="No packets ever observed">📡⚠ never</span>';
|
|
const pktAgo = Date.now() - new Date(o.last_packet_at).getTime();
|
|
const statusAgo = o.last_seen ? Date.now() - new Date(o.last_seen).getTime() : Infinity;
|
|
const gap = pktAgo - statusAgo;
|
|
if (gap > 600000) {
|
|
return `<span title="Last packet ${timeAgo(o.last_packet_at)} — status is newer by ${Math.round(gap/60000)}min. Observer may be alive but not forwarding packets.">📡⚠ ${timeAgo(o.last_packet_at)}</span>`;
|
|
}
|
|
return timeAgo(o.last_packet_at);
|
|
}
|
|
|
|
function uptimeStr(firstSeen) {
|
|
if (!firstSeen) return '—';
|
|
const ms = Date.now() - new Date(firstSeen).getTime();
|
|
const d = Math.floor(ms / 86400000);
|
|
const h = Math.floor((ms % 86400000) / 3600000);
|
|
if (d > 0) return `${d}d ${h}h`;
|
|
const m = Math.floor((ms % 3600000) / 60000);
|
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
}
|
|
|
|
function sparkBar(count, max) {
|
|
if (max === 0) return `<span class="text-muted">0/hr</span>`;
|
|
const pct = Math.min(100, Math.round((count / max) * 100));
|
|
return `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap"><span style="display:inline-block;width:60px;height:12px;background:var(--border);border-radius:3px;overflow:hidden;vertical-align:middle"><span style="display:block;height:100%;width:${pct}%;background:linear-gradient(90deg,#3b82f6,#60a5fa);border-radius:3px"></span></span><span style="font-size:11px">${count}/hr</span></span>`;
|
|
}
|
|
|
|
function render() {
|
|
const el = document.getElementById('obsContent');
|
|
if (!el) return;
|
|
|
|
// Apply region filter
|
|
const selectedRegions = RegionFilter.getSelected();
|
|
const filtered = selectedRegions
|
|
? observers.filter(o => o.iata && selectedRegions.includes(o.iata))
|
|
: observers;
|
|
|
|
if (filtered.length === 0) {
|
|
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">No observers found.</div>';
|
|
return;
|
|
}
|
|
|
|
const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0));
|
|
|
|
// #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 = `
|
|
${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>
|
|
<th scope="col" data-priority="1">Status</th><th scope="col" data-priority="1">Name</th><th scope="col" data-priority="3">Region</th><th scope="col" data-priority="2">Last Status</th><th scope="col" data-priority="2">Last Packet</th>
|
|
<th scope="col" data-priority="3">Packet Health</th><th scope="col" data-priority="4">Total Packets</th><th scope="col" data-priority="3">Packets/Hour</th><th scope="col" data-priority="4">Clock Offset</th><th scope="col" data-priority="4">Uptime</th>
|
|
</tr></thead>
|
|
<tbody>${filtered.map(o => {
|
|
const h = healthStatus(o.last_seen);
|
|
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
|
|
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/observers/${encodeURIComponent(o.id)}" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
|
<td><span class="health-dot ${h.cls}" title="${h.label}">${shape}</span> ${h.label}</td>
|
|
<td class="mono">${escapeHtml(o.name || o.id)}${window.ObserversNaiveChip.render(o)}</td>
|
|
<td>${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
|
|
<td>${timeAgo(o.last_seen)}</td>
|
|
<td>${o.last_packet_at ? timeAgo(o.last_packet_at) : '<span class="text-muted">—</span>'}</td>
|
|
<td>${packetBadge(o)}</td>
|
|
<td>${(o.packet_count || 0).toLocaleString()}</td>
|
|
<td>${sparkBar(o.packetsLastHour || 0, maxPktsHr)}</td>
|
|
<td>${(function() {
|
|
var sk = obsSkewMap[o.id];
|
|
if (!sk || sk.samples == null || sk.samples === 0) return '<span class="text-muted">—</span>';
|
|
var sev = observerSkewSeverity(sk.offsetSec);
|
|
return renderSkewBadge(sev, sk.offsetSec) + ' <span class="text-muted" title="Computed from ' + sk.samples + ' multi-observer packets. Positive = observer ahead of consensus.">(' + sk.samples + ')</span>';
|
|
})()}</td>
|
|
<td>${uptimeStr(o.first_seen)}</td>
|
|
</tr>`;
|
|
}).join('')}</tbody>
|
|
</table></div>`;
|
|
makeColumnsResizable('#obsTable', 'meshcore-obs-col-widths');
|
|
// #1056: fluid columns + +N hidden pill
|
|
if (window.TableResponsive) {
|
|
var _obsTbl = document.getElementById('obsTable');
|
|
if (_obsTbl) window.TableResponsive.register(_obsTbl);
|
|
}
|
|
}
|
|
|
|
|
|
registerPage('observers', { init, destroy });
|
|
|
|
// #1056 AC#4: row-detail slide-over (narrow viewports). Renders a compact
|
|
// summary from the in-memory observer + a link to the full page.
|
|
function openObserverSlideOver(hashHref) {
|
|
if (!window.SlideOver) return;
|
|
var m = String(hashHref || '').match(/#\/observers\/(.+)$/);
|
|
if (!m) return;
|
|
var id = decodeURIComponent(m[1]);
|
|
var o = (observers || []).find(function (x) { return String(x.id) === id; });
|
|
if (!o) return;
|
|
var h = healthStatus(o.last_seen);
|
|
var sk = obsSkewMap[o.id];
|
|
var skewLine = (sk && sk.samples) ? renderSkewBadge(observerSkewSeverity(sk.offsetSec), sk.offsetSec) + ' (' + sk.samples + ' samples)' : '—';
|
|
var pkts = sparkBar(o.packetsLastHour || 0, Math.max(1, o.packetsLastHour || 1));
|
|
var content = window.SlideOver.open({ title: (o.name || o.id) });
|
|
var naiveChipHTML = window.ObserversNaiveChip.render(o);
|
|
content.innerHTML =
|
|
(naiveChipHTML ? '<div style="margin-bottom:10px">' + naiveChipHTML + ' <span class="text-muted">Clock is naive — per-packet timing clamped to ingest time.</span></div>' : '') +
|
|
'<dl class="slide-over-dl" style="margin:0;display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:13px">' +
|
|
'<dt>Status</dt><dd><span class="health-dot ' + h.cls + '">●</span> ' + h.label + '</dd>' +
|
|
'<dt>Region</dt><dd>' + (o.iata ? '<span class="badge-region">' + o.iata + '</span>' : '—') + '</dd>' +
|
|
'<dt>Last status</dt><dd>' + timeAgo(o.last_seen) + '</dd>' +
|
|
'<dt>Last packet</dt><dd>' + (o.last_packet_at ? timeAgo(o.last_packet_at) : '—') + '</dd>' +
|
|
'<dt>Total packets</dt><dd>' + (o.packet_count || 0).toLocaleString() + '</dd>' +
|
|
'<dt>Packets/hr</dt><dd>' + pkts + '</dd>' +
|
|
'<dt>Clock offset</dt><dd>' + skewLine + '</dd>' +
|
|
'<dt>Uptime</dt><dd>' + uptimeStr(o.first_seen) + '</dd>' +
|
|
'</dl>' +
|
|
'<p style="margin-top:14px"><a class="btn-primary" href="' + hashHref + '">Open full detail →</a></p>';
|
|
}
|
|
})();
|