Files
ruvnet--RuView/v2/crates/homecore-server/ui/js/ws.js
T
rUv 20ad75f30c feat(ADR-131): HOMECORE-UI dashboard + BFF gateway — review-fixed (supersedes #1082) (#1099)
* 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>
2026-06-15 11:11:19 -04:00

70 lines
2.7 KiB
JavaScript

// HOMECORE-UI WebSocket client — ADR-130 subscribe_events.
//
// "The UI must never poll for entity state" (ADR-131 §2/§4.4). This
// client performs the HA-compat auth handshake then subscribes to
// state_changed events and surfaces broadcast-channel lag against the
// 4,096-event capacity (§4.1/§4.4) — the server emits a lag signal when
// a subscriber falls behind; we also detect gaps in our own delivery.
import { api } from './api.js';
/**
* Connect and stream events.
* @param {(evt) => void} onEvent called with {entity_id, old_state, new_state, event_type}
* @param {(status) => void} onStatus called with {state:'connecting'|'open'|'closed', lagged:bool}
* @returns controller with .close()
*/
export function connect(onEvent, onStatus) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/websocket`;
let ws, msgId = 1, closedByUs = false, lagged = false;
let retry = 0;
const status = (state) => onStatus && onStatus({ state, lagged });
function open() {
status('connecting');
try { ws = new WebSocket(url); } catch (e) { schedule(); return; }
ws.onmessage = (m) => {
let msg; try { msg = JSON.parse(m.data); } catch { return; }
if (msg.type === 'auth_required') {
ws.send(JSON.stringify({ type: 'auth', access_token: api.token() }));
} else if (msg.type === 'auth_ok') {
retry = 0; status('open');
ws.send(JSON.stringify({ id: msgId++, type: 'subscribe_events', event_type: 'state_changed' }));
} else if (msg.type === 'auth_invalid') {
status('closed');
} else if (msg.type === 'event' && msg.event) {
const e = msg.event;
if (e.event_type === 'state_changed' && e.data) {
onEvent && onEvent({
event_type: 'state_changed',
entity_id: e.data.entity_id,
old_state: e.data.old_state,
new_state: e.data.new_state,
});
} else {
onEvent && onEvent({ event_type: e.event_type, ...e.data });
}
} else if (msg.type === 'lagged' || (msg.type === 'event' && msg.lagged)) {
lagged = true; status('open');
}
};
ws.onclose = () => { if (!closedByUs) schedule(); else status('closed'); };
ws.onerror = () => { try { ws.close(); } catch {} };
}
function schedule() {
status('closed');
retry = Math.min(retry + 1, 6);
const delay = Math.min(500 * 2 ** retry, 15000);
setTimeout(() => { if (!closedByUs) open(); }, delay);
}
open();
return {
close() { closedByUs = true; try { ws && ws.close(); } catch {} },
isLagged: () => lagged,
clearLag() { lagged = false; },
};
}