mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 16:07:55 +00:00
## Summary Issue #1478 — surface observers whose envelope timestamps are being clamped because they're emitting zone-less local-time strings (UTC-N observers showed up perpetually as "Stale" before #1466, and per-packet rxTime is still clamped to ingest time for them, muddying propagation-delay analytics). Now the UI tells operators which observers are misconfigured + how to fix it. ## What changed ### Ingestor (cmd/ingestor) - New `observers_clock_naive_v1` migration adds three columns to `observers`: - `clock_skew_seconds INTEGER` (signed: negative = behind UTC, positive = ahead) - `clock_skew_count_24h INTEGER` (rolling 24h event count) - `clock_last_naive_at TEXT` (RFC3339 timestamp of last clamp) - `resolveRxTime` now returns `(rxTime, naiveSkewSec)`. The packet-handler call site invokes `store.RecordNaiveSkew(observerID, deltaSec)` whenever a naive envelope is clamped (the existing >15 min naive-tolerance path). The counter resets to 1 if no event in the prior 24h, else increments. Single INSERT-or-UPDATE round trip per clamp. ### Server (cmd/server) - `Observer` struct + `GetObservers` / `GetObserverByID` extended to scan the three new columns. - `ObserverResp` gains four JSON fields exposed by `/api/observers` and `/api/observers/{id}`: - `clock_naive` (bool, derived from `clock_last_naive_at` being within 24h) - `clock_skew_seconds`, `clock_skew_count_24h`, `clock_last_naive_at` - Decay is **read-side**: a stale event yields `clock_naive=false` with zero counts. No background sweep, no writes from the read-only server, no race with the ingestor. ### Frontend (public) - `window.ObserversNaiveChip.render(o)` — total render helper, returns ⚠️ chip HTML when `o.clock_naive===true`, `""` otherwise. Used inline in the observers-list `name` cell and in the row-detail slide-over. Tooltip explains magnitude + direction + count + fix. - `window.ObserverDetailNaiveBanner.render(obs)` — yellow alert banner at the top of the observer-detail page with the skew magnitude, last-event timestamp, and the actionable fix ("Set host clock to UTC, OR emit Z-suffixed/offset-aware timestamps from the observer script"). ## TDD trail - `5ddd5b42` red: backend `cmd/server/observer_naive_clock_1478_test.go` (3 tests asserting JSON fields + 24h decay) + frontend `test-observer-naive-clock-1478.js` (8 jsdom-style tests asserting helpers exist and render correctly). Both failed on master with field-missing / export-missing assertions. - `4ecc79c8` green backend: schema + Observer / GetObservers / ObserverResp / handler decay. - `2137ab81` green frontend: chip + banner helpers and call sites. ## Tests - `cd cmd/server && go test ./...` → all green (full suite, 46s) - `cd cmd/ingestor && go test ./...` → all green (full suite, 98s) - `node test-observer-naive-clock-1478.js` → 8/8 pass - `node test-frontend-helpers.js` → unchanged from master (pre-existing failures only) ## Acceptance (issue #1478) - ✅ Observer running with `python datetime.now().isoformat()` (naive, off by N hours) → `clock_naive=true` after the next clamp → UI shows ⚠️ chip + banner. - ✅ Observer with `datetime.now(timezone.utc).isoformat()` (Z-suffixed) → never clamped → never flagged. - ✅ Observer that fixed its clock → `clock_naive` returns to `false` 24h after the last clamp event (read-side decay). Closes #1478. --------- Co-authored-by: openclaw <bot@openclaw.local>
145 lines
5.4 KiB
JavaScript
145 lines
5.4 KiB
JavaScript
/* Issue #1478 — frontend renders ⚠️ chip on observers list when clock_naive,
|
|
* and a prominent banner on observer-detail page. Behavioral test via JSDOM-
|
|
* style sandbox: we load the production JS files into a vm context with a
|
|
* minimal DOM, call the exposed helper functions, and assert on the rendered
|
|
* HTML strings.
|
|
*
|
|
* Production code must expose:
|
|
* window.ObserversNaiveChip.render(o) -> string (chip HTML or "")
|
|
* window.ObserverDetailNaiveBanner.render(o) -> string (banner HTML or "")
|
|
* Both must return non-empty content when `o.clock_naive === true`, with the
|
|
* skew magnitude in the chip's tooltip and the actionable recommendation in
|
|
* the banner body.
|
|
*/
|
|
'use strict';
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const assert = require('assert');
|
|
|
|
let passed = 0, failed = 0;
|
|
function test(name, fn) {
|
|
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
|
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
|
}
|
|
|
|
function makeCtx() {
|
|
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,
|
|
String,
|
|
Number,
|
|
Boolean,
|
|
JSON,
|
|
setInterval: () => 0,
|
|
clearInterval: () => {},
|
|
setTimeout: (fn) => { try { fn(); } catch {} return 0; },
|
|
encodeURIComponent,
|
|
decodeURIComponent,
|
|
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
|
};
|
|
// common globals used by observers.js
|
|
ctx.registerPage = () => {};
|
|
ctx.RegionFilter = { init: () => {}, onChange: () => () => {}, getSelected: () => null };
|
|
ctx.healthStatus = () => ({ cls: 'health-green', label: 'Online' });
|
|
ctx.timeAgo = () => 'now';
|
|
ctx.uptimeStr = () => '—';
|
|
ctx.packetBadge = () => '';
|
|
ctx.sparkBar = () => '';
|
|
ctx.makeColumnsResizable = () => {};
|
|
ctx.observerSkewSeverity = () => 'ok';
|
|
ctx.renderSkewBadge = () => '';
|
|
ctx.debouncedOnWS = (fn) => fn;
|
|
vm.createContext(ctx);
|
|
return ctx;
|
|
}
|
|
|
|
console.log('\n=== Issue #1478 — Observers list ⚠️ chip ===');
|
|
|
|
(function () {
|
|
const ctx = makeCtx();
|
|
vm.runInContext(fs.readFileSync('public/observers.js', 'utf8'), ctx);
|
|
|
|
test('window.ObserversNaiveChip.render exists', () => {
|
|
assert.ok(ctx.window.ObserversNaiveChip, 'ObserversNaiveChip not exported');
|
|
assert.strictEqual(typeof ctx.window.ObserversNaiveChip.render, 'function');
|
|
});
|
|
|
|
test('renders ⚠️ chip when clock_naive=true', () => {
|
|
const html = ctx.window.ObserversNaiveChip.render({
|
|
id: 'x', name: 'X', clock_naive: true, clock_skew_seconds: -28800,
|
|
clock_skew_count_24h: 17,
|
|
});
|
|
assert.ok(html && html.length > 0, 'expected non-empty chip HTML');
|
|
assert.ok(/⚠️|warning/i.test(html), `expected warning glyph in chip: ${html}`);
|
|
assert.ok(/title=/.test(html), `expected title= tooltip in chip: ${html}`);
|
|
});
|
|
|
|
test('renders nothing when clock_naive=false', () => {
|
|
const html = ctx.window.ObserversNaiveChip.render({ id: 'y', clock_naive: false });
|
|
assert.strictEqual(html || '', '');
|
|
});
|
|
|
|
test('renders nothing when clock_naive missing', () => {
|
|
const html = ctx.window.ObserversNaiveChip.render({ id: 'z' });
|
|
assert.strictEqual(html || '', '');
|
|
});
|
|
|
|
test('chip tooltip mentions skew magnitude', () => {
|
|
const html = ctx.window.ObserversNaiveChip.render({
|
|
id: 'x', clock_naive: true, clock_skew_seconds: -28800,
|
|
});
|
|
// -28800s = 8h. Tooltip should mention hours or the duration so an
|
|
// operator can see at a glance "off by 8 hours" without clicking through.
|
|
assert.ok(/8|h/.test(html), `expected magnitude in tooltip: ${html}`);
|
|
});
|
|
})();
|
|
|
|
console.log('\n=== Issue #1478 — Observer detail banner ===');
|
|
|
|
(function () {
|
|
const ctx = makeCtx();
|
|
// observer-detail.js needs Chart
|
|
ctx.Chart = { defaults: {}, register: () => {} };
|
|
ctx.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
|
vm.runInContext(fs.readFileSync('public/observer-detail.js', 'utf8'), ctx);
|
|
|
|
test('window.ObserverDetailNaiveBanner.render exists', () => {
|
|
assert.ok(ctx.window.ObserverDetailNaiveBanner, 'ObserverDetailNaiveBanner not exported');
|
|
assert.strictEqual(typeof ctx.window.ObserverDetailNaiveBanner.render, 'function');
|
|
});
|
|
|
|
test('renders banner when clock_naive=true', () => {
|
|
const html = ctx.window.ObserverDetailNaiveBanner.render({
|
|
id: 'x', name: 'X', clock_naive: true, clock_skew_seconds: -28800,
|
|
clock_skew_count_24h: 17, clock_last_naive_at: new Date().toISOString(),
|
|
});
|
|
assert.ok(html && html.length > 0, 'expected non-empty banner HTML');
|
|
// Banner must contain actionable guidance — operator needs to know HOW
|
|
// to fix it (set clock to UTC, or emit zone-aware timestamps).
|
|
assert.ok(/UTC/i.test(html), `expected UTC mention: ${html}`);
|
|
assert.ok(/(Z-suffix|offset-aware|timezone|zone-aware|isoformat)/i.test(html),
|
|
`expected timestamp guidance: ${html}`);
|
|
});
|
|
|
|
test('renders nothing when clock_naive=false', () => {
|
|
const html = ctx.window.ObserverDetailNaiveBanner.render({ id: 'y', clock_naive: false });
|
|
assert.strictEqual(html || '', '');
|
|
});
|
|
})();
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed === 0 ? 0 : 1);
|