mirror of
https://github.com/ruvnet/RuView
synced 2026-07-04 14:23: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>
142 lines
4.9 KiB
JavaScript
142 lines
4.9 KiB
JavaScript
// HOMECORE-UI bootstrap + shell + router — ADR-131 §5.
|
|
//
|
|
// Builds the Cognitum-shell top nav (Framework | Guide | Cog Store |
|
|
// HOMECORE | Status) with HOMECORE active, a left sub-nav for the nine
|
|
// HOMECORE sections, and a hash router. One shared WebSocket feeds a bus
|
|
// that every panel subscribes to (no per-panel sockets, no polling).
|
|
|
|
import { h, clear, lagIndicator } from './ui.js';
|
|
import { api } from './api.js';
|
|
import { connect } from './ws.js';
|
|
|
|
import dashboard from './panels/dashboard.js';
|
|
import fleet from './panels/fleet.js';
|
|
import seedDetail from './panels/seed-detail.js';
|
|
import entities from './panels/entities.js';
|
|
import rooms from './panels/rooms.js';
|
|
import cogs from './panels/cogs.js';
|
|
import calibration from './panels/calibration.js';
|
|
import events from './panels/events.js';
|
|
import audit from './panels/audit.js';
|
|
import settings from './panels/settings.js';
|
|
|
|
// Section registry. order drives the left sub-nav (§5).
|
|
const SECTIONS = [
|
|
{ id: 'dashboard', label: 'Dashboard', icon: '◳', mod: dashboard },
|
|
{ id: 'fleet', label: 'SEED Fleet', icon: '⬡', mod: fleet },
|
|
{ id: 'entities', label: 'Entities', icon: '◈', mod: entities },
|
|
{ id: 'rooms', label: 'Rooms', icon: '⌂', mod: rooms },
|
|
{ id: 'cogs', label: 'COGs', icon: '⚙', mod: cogs },
|
|
{ id: 'calibration', label: 'Calibration', icon: '⊹', mod: calibration },
|
|
{ id: 'events', label: 'Events', icon: '⚡', mod: events },
|
|
{ id: 'audit', label: 'Audit', icon: '⛨', mod: audit },
|
|
{ id: 'settings', label: 'Settings', icon: '⚒', mod: settings },
|
|
];
|
|
// Detail routes not shown in the sub-nav.
|
|
const ROUTES = { 'seed': seedDetail };
|
|
|
|
// Shared event bus fed by the single WS connection.
|
|
const bus = new EventTarget();
|
|
let wsState = { state: 'connecting', lagged: false };
|
|
|
|
const ctx = {
|
|
api,
|
|
bus,
|
|
wsStatus: () => wsState,
|
|
navigate: (hash) => { location.hash = hash; },
|
|
onEvent(handler) {
|
|
const fn = (e) => handler(e.detail);
|
|
bus.addEventListener('hc-event', fn);
|
|
return () => bus.removeEventListener('hc-event', fn);
|
|
},
|
|
onWs(handler) {
|
|
const fn = (e) => handler(e.detail);
|
|
bus.addEventListener('hc-ws', fn);
|
|
handler(wsState);
|
|
return () => bus.removeEventListener('hc-ws', fn);
|
|
},
|
|
};
|
|
|
|
let cleanup = null;
|
|
|
|
function buildShell() {
|
|
const topnav = h('.topnav',
|
|
h('.brand',
|
|
h('span.logo', 'C'),
|
|
h('span.brand-name', 'Cognitum'),
|
|
h('span.brand-sep', '/'),
|
|
h('span.brand-tag', 'HOMECORE')),
|
|
h('span.nav-spacer'),
|
|
lagIndicatorHost());
|
|
const sidenav = h('.sidenav', ...SECTIONS.map((s) => sideLink(s)));
|
|
const content = h('.content#hc-content');
|
|
const shell = h('.shell', sidenav, content);
|
|
const root = document.getElementById('app');
|
|
clear(root);
|
|
root.appendChild(topnav);
|
|
root.appendChild(shell);
|
|
return content;
|
|
}
|
|
|
|
function sideLink(section) {
|
|
return h('a', { href: '#/' + section.id, 'data-section': section.id },
|
|
h('span.ico', section.icon || '•'), h('span.lbl', section.label));
|
|
}
|
|
|
|
function lagIndicatorHost() {
|
|
const host = h('span');
|
|
const paint = () => { clear(host); host.appendChild(lagIndicator(wsState.state, wsState.lagged)); };
|
|
bus.addEventListener('hc-ws', paint);
|
|
paint();
|
|
return host;
|
|
}
|
|
|
|
function highlightNav(id) {
|
|
document.querySelectorAll('.sidenav a').forEach((a) => {
|
|
a.classList.toggle('active', a.getAttribute('data-section') === id);
|
|
});
|
|
}
|
|
|
|
async function route() {
|
|
const hash = location.hash.replace(/^#\/?/, '') || 'dashboard';
|
|
const [head, ...rest] = hash.split('/');
|
|
const content = document.getElementById('hc-content') || buildShell();
|
|
|
|
if (typeof cleanup === 'function') { try { cleanup(); } catch {} cleanup = null; }
|
|
clear(content);
|
|
|
|
let mod, params = {};
|
|
const section = SECTIONS.find((s) => s.id === head);
|
|
if (section) { mod = section.mod; highlightNav(head); }
|
|
else if (ROUTES[head]) { mod = ROUTES[head]; params.id = rest[0]; highlightNav('fleet'); }
|
|
else { mod = SECTIONS[0].mod; highlightNav('dashboard'); }
|
|
|
|
try {
|
|
const result = await mod.render(content, { ...ctx, params });
|
|
if (typeof result === 'function') cleanup = result;
|
|
} catch (e) {
|
|
content.appendChild(h('.banner.red', 'Panel error: ' + (e && e.message ? e.message : e)));
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
function start() {
|
|
buildShell();
|
|
// Attach routing + render the first panel BEFORE opening the socket.
|
|
// connect() invokes its status callback synchronously, so the WS wiring
|
|
// must not be on the critical render path (a thrown callback here would
|
|
// otherwise blank the whole dashboard).
|
|
window.addEventListener('hashchange', route);
|
|
route();
|
|
const ctrl = connect(
|
|
(evt) => bus.dispatchEvent(new CustomEvent('hc-event', { detail: evt })),
|
|
(st) => { wsState = { state: st.state, lagged: !!st.lagged }; bus.dispatchEvent(new CustomEvent('hc-ws', { detail: wsState })); },
|
|
);
|
|
ctx.ws = ctrl;
|
|
}
|
|
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
|
|
else start();
|
|
|
|
export { SECTIONS, ctx };
|