mirror of
https://github.com/ruvnet/RuView
synced 2026-07-22 17:23:19 +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>
104 lines
5.3 KiB
JavaScript
104 lines
5.3 KiB
JavaScript
// Minimal DOM shim — enough to *run* the HOMECORE-UI panels under Node
|
|
// without jsdom. Installs globals (document, location, localStorage,
|
|
// fetch, WebSocket) so render-smoke.mjs can execute every panel and
|
|
// assert it builds a real DOM subtree without throwing.
|
|
|
|
class ClassList {
|
|
constructor(el) { this.el = el; this.set = new Set(); }
|
|
add(...c) { c.forEach((x) => x && this.set.add(x)); this.sync(); }
|
|
remove(...c) { c.forEach((x) => this.set.delete(x)); this.sync(); }
|
|
toggle(c, force) { const has = this.set.has(c); const on = force === undefined ? !has : force; if (on) this.set.add(c); else this.set.delete(c); this.sync(); return on; }
|
|
contains(c) { return this.set.has(c); }
|
|
sync() { this.el._class = [...this.set].join(' '); }
|
|
}
|
|
|
|
class El {
|
|
constructor(tag) {
|
|
this.tagName = String(tag).toUpperCase();
|
|
this.children = [];
|
|
this.attrs = {};
|
|
this.style = {};
|
|
this.listeners = {};
|
|
this._class = '';
|
|
this.classList = new ClassList(this);
|
|
this.parentNode = null;
|
|
this.id = '';
|
|
this._text = '';
|
|
this.disabled = false;
|
|
this.value = '';
|
|
}
|
|
set className(v) { this._class = v || ''; this.classList.set = new Set(String(v || '').split(/\s+/).filter(Boolean)); }
|
|
get className() { return this._class; }
|
|
set innerHTML(v) { this._html = v; }
|
|
get innerHTML() { return this._html || ''; }
|
|
set textContent(v) { this._text = v; this.children = []; }
|
|
get textContent() { return this._text || this.children.map((c) => c.textContent || c._text || '').join(''); }
|
|
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
|
|
insertBefore(c, ref) { const i = this.children.indexOf(ref); c.parentNode = this; if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); return c; }
|
|
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parentNode = null; return c; }
|
|
remove() { if (this.parentNode) this.parentNode.removeChild(this); }
|
|
get firstChild() { return this.children[0] || null; }
|
|
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
getAttribute(k) { return this.attrs[k] ?? null; }
|
|
addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
|
|
removeEventListener(t, fn) { this.listeners[t] = (this.listeners[t] || []).filter((f) => f !== fn); }
|
|
dispatch(t, detail) { (this.listeners[t] || []).forEach((fn) => fn({ detail, target: this, preventDefault() {}, stopPropagation() {} })); }
|
|
_all() { return this.children.flatMap((c) => [c, ...(c._all ? c._all() : [])]); }
|
|
matchesSel(sel) {
|
|
return sel.split(/\s+/).pop().split('.').every((p, i, arr) => {
|
|
if (i === 0 && p && !p.startsWith('.') && !p.startsWith('#')) { if (p.startsWith('.')) {} }
|
|
return true;
|
|
});
|
|
}
|
|
querySelector(sel) {
|
|
const want = sel.replace(/^.*\s/, '');
|
|
const cls = want.startsWith('.') ? want.slice(1) : null;
|
|
return this._all().find((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase())) || null;
|
|
}
|
|
querySelectorAll(sel) {
|
|
const want = sel.replace(/^.*\s/, '');
|
|
const cls = want.startsWith('.') ? want.slice(1) : null;
|
|
return this._all().filter((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase()));
|
|
}
|
|
}
|
|
|
|
class TextNode { constructor(t) { this.textContent = String(t); this._text = String(t); this.nodeType = 3; this.parentNode = null; } remove() { if (this.parentNode) this.parentNode.removeChild(this); } }
|
|
|
|
// Node instanceof checks in ui.js use `instanceof Node`; expose a Node base.
|
|
globalThis.Node = El;
|
|
// TextNode must also pass `instanceof Node` (ui.js append() treats text via createTextNode).
|
|
Object.setPrototypeOf(TextNode.prototype, El.prototype);
|
|
|
|
const body = new El('body');
|
|
const documentObj = {
|
|
createElement: (t) => new El(t),
|
|
createElementNS: (_ns, t) => new El(t),
|
|
createTextNode: (t) => new TextNode(t),
|
|
getElementById: (id) => byId[id] || (byId[id] = mkRoot(id)),
|
|
body,
|
|
readyState: 'complete',
|
|
addEventListener() {},
|
|
querySelectorAll: () => [],
|
|
};
|
|
const byId = {};
|
|
function mkRoot(id) { const e = new El('div'); e.id = id; return e; }
|
|
|
|
export function install() {
|
|
globalThis.document = documentObj;
|
|
globalThis.EventTarget = class { constructor() { this._l = {}; } addEventListener(t, fn) { (this._l[t] ||= []).push(fn); } removeEventListener(t, fn) { this._l[t] = (this._l[t] || []).filter((f) => f !== fn); } dispatchEvent(e) { (this._l[e.type] || []).forEach((fn) => fn(e)); return true; } };
|
|
// window with a navigable location.hash that fires `hashchange`.
|
|
const win = new globalThis.EventTarget();
|
|
let _hash = '';
|
|
const loc = { host: 'localhost:8123', protocol: 'http:', get hash() { return _hash; }, set hash(v) { _hash = String(v).startsWith('#') ? String(v) : '#' + v; win.dispatchEvent({ type: 'hashchange' }); } };
|
|
win.location = loc;
|
|
globalThis.window = win;
|
|
globalThis.location = loc;
|
|
globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); } };
|
|
globalThis.fetch = () => Promise.reject(new Error('offline (test) — panels fall back to mock per §7.1'));
|
|
globalThis.WebSocket = class { constructor() { this.readyState = 0; } send() {} close() {} };
|
|
globalThis.CustomEvent = class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } };
|
|
return { El, TextNode, body, document: documentObj, window: win, location: loc };
|
|
}
|
|
|
|
export { El, TextNode };
|