Files
meshcore-analyzer/test-observer-naive-clock-1478.js
T
Kpa-clawbotandopenclaw 43b93c6bb9 feat(observers): surface naive-clock observers as ⚠️ chip + detail banner (#1478) (#1480)
## 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>
2026-05-29 01:08:12 -07:00

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);