mirror of
https://github.com/ruvnet/RuView
synced 2026-07-17 16:33:18 +00:00
20ad75f30c
* feat(ADR-131): HOMECORE-UI operational dashboard + BFF gateway Complete two-tier Cognitum operator dashboard (ADR-131), served by homecore-server at /homecore, plus the single-origin BFF gateway that wires it to real backends. Front-end (zero-dep vanilla TS/JS + CSS, exact Cognitum design tokens): - All 10 panels (§4.1-4.10): dashboard, SEED fleet + detail, fleet map, entities (live WS subscribe_events, never polls), rooms, COGs, calibration wizard, events + automation builder, witness/audit, settings. - §6 UX invariants in code: first-class provenance, prominent stale/veto/ fragility, null(not-trained) vs withheld vs error, --mono everywhere, Hailo vs CPU COG distinction. - api.js calls the gateway routes in production; mock demoted to a dev-only ?demo=1 fixture (no mock in prod); typed error states. - Tests under plain node: import-graph, boot, render-smoke (22), interaction (3), prod-errors (13) — 5 files green; bundle ~137 KB (~37x smaller than HA), <2 ms/cold-render. BFF gateway (homecore-server/src/gateway.rs, compiled + tested on Rust 1.89): - /api/cal/* reverse-proxy to the calibration API (ADR-151). - GET /api/homecore/rooms with the RoomState adapter (breathing->breathing_bpm, heartbeat:null->heart_bpm:null, injected anomaly.threshold/room_id). - GET /api/homecore/cogs supervisor over /var/lib/cognitum/apps/. - GET /api/homecore/appliance from /proc + TCP service probes. - SEED-device/appliance routes return typed 503 upstream_unavailable. - cargo test -p homecore-server = 12/12; run live (curl-verified); fixed a real double-v1 proxy-URL bug found during live testing. Honest scope: W1/W2/W4/W6-appliance functional; W3/W5/W6-Hailo/federation return typed 503 (depend on services/hardware not in this repo). Co-Authored-By: claude-flow <ruv@ruv.net> * fix(homecore-ui): resolve code-review findings — SSRF guard, CORS/trace coverage, §6 honesty, crash guards Addresses the high-effort review of PR #1082: - SECURITY: cal_proxy rejects path-traversal/confused-deputy SSRF (`.`/`..` segments, backslash, %2e%2e/%2f, absolute) on raw+decoded forms → 400, before attaching the server-side calibration bearer. - CORRECTNESS: /api/homecore/* + /api/cal/* now covered by the shared CORS allowlist (build_cors_layer, exported from homecore-api) + TraceLayer — previously merged outside router()'s layers (no CORS, no tracing). - §6 HONESTY (no fabricated data): dashboard renders '—' for null metrics (not "null%"/"null°C"); cogs Hailo pill reflects the REAL appliance probe (not hardcoded "connected"); room anomaly threshold passed through / null, not a fabricated 0.5. - ROBUSTNESS: cogs asArray(hef) guards a non-array manifest field; calibration progress guards target<=0 (no NaN%/Infinity%); restart clears the poll timer. - CLEANUP: mock.js is now a cached DYNAMIC import (demo-only) — never bundled in production (§2.2). - New ui/tests/unit-fixes.mjs pins the above; ADR-131 + CHANGELOG updated. Co-Authored-By: claude-flow <ruv@ruv.net> --------- Co-authored-by: Nick Ruest <127058086+nicholas-ruest@users.noreply.github.com>
110 lines
5.7 KiB
JavaScript
110 lines
5.7 KiB
JavaScript
// Render-smoke test — actually executes every HOMECORE-UI panel against
|
|
// the DOM shim and asserts each builds a non-empty DOM subtree without
|
|
// throwing. Also exercises the ui.js helpers and the mock contract.
|
|
// Run: node tests/render-smoke.mjs (from the ui/ dir)
|
|
import { install } from './dom-shim.mjs';
|
|
install();
|
|
globalThis.HOMECORE_UI_DEMO = true; // render panels against fixtures
|
|
|
|
const fails = [];
|
|
const passes = [];
|
|
function check(name, fn) {
|
|
try { fn(); passes.push(name); }
|
|
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
|
}
|
|
async function checkAsync(name, fn) {
|
|
try { await fn(); passes.push(name); }
|
|
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
|
}
|
|
|
|
const ui = await import('../js/ui.js');
|
|
const { api, entityProvenance } = await import('../js/api.js');
|
|
const mock = await import('../js/mock.js');
|
|
|
|
// ── ui.js helper unit checks ────────────────────────────────────────
|
|
check('ui.h builds element with class/id', () => {
|
|
const n = ui.h('div.card#x', { 'data-k': 'v' }, 'hi');
|
|
if (n.tagName !== 'DIV') throw new Error('tag');
|
|
if (!n.classList.contains('card')) throw new Error('class');
|
|
if (n.id !== 'x') throw new Error('id');
|
|
});
|
|
check('ui.statusPill maps running→green', () => {
|
|
const p = ui.statusPill('running');
|
|
if (!p.classList.contains('green')) throw new Error('expected green pill');
|
|
});
|
|
check('ui.statusPill maps offline→red', () => {
|
|
if (!ui.statusPill('offline').classList.contains('red')) throw new Error('expected red');
|
|
});
|
|
check('ui.bar applies threshold colour', () => {
|
|
const b = ui.bar(0.9, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
|
|
if (!b.firstChild.classList.contains('red')) throw new Error('expected red fill at 0.9');
|
|
});
|
|
check('ui.confidenceBar amber under 0.4', () => {
|
|
if (!ui.confidenceBar(0.2).firstChild.classList.contains('amber')) throw new Error('low conf should be amber');
|
|
});
|
|
check('ui.provenanceBadge marks hailo', () => {
|
|
const p = ui.provenanceBadge({ esp32: 'e', seed: 's', cog: 'c', hailo: true });
|
|
if (!p.querySelector('.hailo')) throw new Error('hailo class missing');
|
|
});
|
|
check('ui.sparkline yields svg polyline', () => {
|
|
const s = ui.sparkline([1, 2, 3, 4]);
|
|
if (!s.querySelector('polyline')) throw new Error('no polyline');
|
|
});
|
|
|
|
// ── mock contract checks ────────────────────────────────────────────
|
|
check('mock RoomState distinguishes null vs withheld', () => {
|
|
const rs = mock.roomStates();
|
|
const office = rs.find((r) => r.room_id === 'office');
|
|
if (office.posture !== null) throw new Error('office posture should be null (not trained)');
|
|
const kitchen = rs.find((r) => r.room_id === 'kitchen');
|
|
if (!kitchen.vetoed) throw new Error('kitchen should be vetoed');
|
|
if (kitchen.posture.value !== null) throw new Error('vetoed posture value should be null/withheld, not zero');
|
|
});
|
|
check('analysis covers at least 3 bedrooms', () => {
|
|
const beds = mock.roomStates().filter((r) => /^bedroom/.test(r.room_id));
|
|
if (beds.length < 3) throw new Error(`expected ≥3 bedrooms in RoomState analysis, got ${beds.length}`);
|
|
const bedSeeds = mock.seeds().filter((s) => /bedroom/i.test(s.zone));
|
|
if (bedSeeds.length < 3) throw new Error(`expected ≥3 bedroom SEED nodes, got ${bedSeeds.length}`);
|
|
});
|
|
check('mock fleet has an offline seed with red tint semantics', () => {
|
|
if (!mock.seeds().some((s) => !s.online)) throw new Error('need an offline seed for §4.1 tint');
|
|
});
|
|
check('mock federation states the raw-CSI invariant', () => {
|
|
if (!/never raw CSI/i.test(mock.federation().invariant)) throw new Error('invariant text missing');
|
|
});
|
|
check('entityProvenance derives node→seed chain', () => {
|
|
const prov = entityProvenance({ attributes: { source: 'esp32-lr-01 BFLD' } });
|
|
if (prov.esp32 !== 'esp32-lr-01') throw new Error('node parse failed');
|
|
if (!prov.seed) throw new Error('seed mapping failed');
|
|
});
|
|
|
|
// ── render every panel ──────────────────────────────────────────────
|
|
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
|
|
const ctx = {
|
|
api,
|
|
navigate() {},
|
|
params: { id: 'seed-livingroom-a1' },
|
|
onEvent() { return () => {}; },
|
|
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
|
|
wsStatus: () => ({ state: 'open', lagged: false }),
|
|
bus: new globalThis.EventTarget(),
|
|
};
|
|
|
|
for (const name of PANELS) {
|
|
await checkAsync(`render panel: ${name}`, async () => {
|
|
const mod = await import(`../js/panels/${name}.js`);
|
|
const panel = mod.default;
|
|
if (!panel || typeof panel.render !== 'function') throw new Error('no default.render export');
|
|
if (!panel.meta || !panel.meta.title) throw new Error('missing meta.title');
|
|
const root = document.createElement('div');
|
|
const cleanup = await panel.render(root, ctx);
|
|
if (root.children.length === 0) throw new Error('rendered nothing into root');
|
|
if (cleanup && typeof cleanup === 'function') cleanup(); // must not throw
|
|
});
|
|
}
|
|
|
|
// ── report ──────────────────────────────────────────────────────────
|
|
console.log(`\n${passes.length} passed, ${fails.length} failed`);
|
|
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
|
console.log('OK — all ui helpers, mock contracts, and 10 panels render without throwing');
|