mirror of
https://github.com/ruvnet/RuView
synced 2026-07-22 17:23:19 +00:00
39ec05edcb
End-to-end implementation of the operator dashboard for the nvsim
NV-diamond magnetometer simulator. Vite 5 + TypeScript strict + Lit 3,
~93 KB gzipped JS budget, runs the *real* nvsim Rust crate compiled to
wasm32-unknown-unknown inside a dedicated Web Worker.
Validated end-to-end with `npx agent-browser`:
- WASM module boots, build version + magic 0xC51A_6E70 reported
- Reference witness verifies byte-identical to Proof::EXPECTED_WITNESS_HEX
cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4
- Pipeline runs at ~1.88 kHz on x86_64 dev hardware (4500x over Cortex-A53)
- Zero browser console errors; only Lit dev-mode warning (expected)
## nvsim crate (additive)
- New `wasm` feature flag with wasm-bindgen 0.2 / serde-wasm-bindgen 0.6
- src/wasm.rs: WasmPipeline wrapper + referenceSceneJson +
expectedReferenceWitnessHex + referenceWitness + hexWitness exports
- crate-type = ["cdylib", "rlib"] so native + wasm both build
- rand = { default-features = false } drops getrandom OS-entropy path,
preserving the crate's WASM-ready posture
- Native: 50/50 tests still pass, witness unchanged
## dashboard/ (new package)
- Vite 5 + TypeScript strict, Lit 3 elements, signals-based store
- 12 Lit components mirroring the mockup zones (rail, topbar, sidebar,
scene SVG with draggable sources + NV crystal, inspector tabs
Signal/Frame/Witness, console with REPL + filter tabs, settings
drawer, modals, ⌘K command palette, debug HUD, toast, app-store)
- IndexedDB persistence (theme, density, motion, app activations)
- WasmClient → Web Worker → wasm-pack-built nvsim WASM module
- NvsimClient TS interface — same shape covers future WsClient transport
- MagFrame parser (60-byte LE layout matching nvsim::frame)
## App Store (ADR-092 §14a — added during impl)
- Catalog of all 65 wifi-densepose-wasm-edge modules + nvsim
- 13 categories with event-ID-range labels
- Per-app metadata: id/name/category/crate/summary/events/budget/
status/adr/tags
- Fuzzy search, category + status filters, IndexedDB-backed activation
- ADR-092 §14a documents the registry contract and per-app schema
## Build pipeline
- wasm-pack build crates/nvsim --target web outputs to
dashboard/public/nvsim-pkg/ (60 KB pkg, 162 KB unoptimized .wasm)
- npm run build → 93 KB gzip JS, well under 300 KB budget
- ts.config strict, npx tsc --noEmit clean
- Vite worker correctly loads WASM via dynamic import resolving
against worker origin
## E2E validation
- agent-browser open → 4-zone grid renders correctly in dark theme
- Run button → live B-vector trace, |B| readout updates, FPS counter
- App Store → all 66 apps listed with toggles, fuzzy search filters
to "Ghost hunter" on "ghost" query
- Witness verify → green check, console logs "determinism gate ✓"
- Console errors: zero (only expected Lit dev-mode warning)
Co-Authored-By: claude-flow <ruv@ruv.net>
250 lines
9.3 KiB
TypeScript
250 lines
9.3 KiB
TypeScript
/* Console — log stream + REPL. */
|
||
import { LitElement, html, css } from 'lit';
|
||
import { customElement, query } from 'lit/decorators.js';
|
||
import { effect } from '@preact/signals-core';
|
||
import {
|
||
consoleLines, consoleFilter, consolePaused, pushLog,
|
||
getClient, seed, theme, expectedWitness, witnessHex, witnessVerified,
|
||
running,
|
||
} from '../store/appStore';
|
||
|
||
@customElement('nv-console')
|
||
export class NvConsole extends LitElement {
|
||
@query('#console-input') private inputEl!: HTMLInputElement;
|
||
private history: string[] = [];
|
||
private hIdx = -1;
|
||
|
||
static styles = css`
|
||
:host {
|
||
display: flex; flex-direction: column;
|
||
background: var(--bg-1);
|
||
overflow: hidden;
|
||
}
|
||
.tabs {
|
||
display: flex; align-items: center;
|
||
border-bottom: 1px solid var(--line);
|
||
padding: 0 10px;
|
||
gap: 2px;
|
||
}
|
||
.tab {
|
||
padding: 8px 12px;
|
||
background: transparent; border: none;
|
||
font-size: 11.5px; color: var(--ink-3);
|
||
font-family: var(--mono);
|
||
border-bottom: 2px solid transparent;
|
||
cursor: pointer;
|
||
margin-bottom: -1px;
|
||
}
|
||
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
||
.tab .cnt {
|
||
background: var(--bg-3); padding: 1px 5px; border-radius: 999px;
|
||
font-size: 9.5px; color: var(--ink-2); margin-left: 4px;
|
||
}
|
||
.spacer { flex: 1; }
|
||
.tools { display: flex; gap: 4px; padding: 4px 0; }
|
||
.tools button {
|
||
width: 24px; height: 24px;
|
||
background: transparent; border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
color: var(--ink-3);
|
||
font-size: 11px; cursor: pointer;
|
||
}
|
||
.tools button:hover { color: var(--ink); border-color: var(--line-2); }
|
||
|
||
.body {
|
||
flex: 1; overflow-y: auto;
|
||
font-family: var(--mono);
|
||
font-size: 11.5px;
|
||
padding: 6px 0;
|
||
background: var(--bg-0);
|
||
}
|
||
.line {
|
||
display: grid;
|
||
grid-template-columns: 70px 60px 1fr;
|
||
gap: 12px;
|
||
padding: 2px 12px;
|
||
color: var(--ink-2);
|
||
border-left: 2px solid transparent;
|
||
}
|
||
.line:hover { background: var(--bg-1); }
|
||
.ts { color: var(--ink-4); font-size: 10.5px; padding-top: 1px; }
|
||
.lvl {
|
||
font-size: 10px; font-weight: 600;
|
||
text-transform: uppercase; letter-spacing: 0.04em; padding-top: 1px;
|
||
}
|
||
.line.info .lvl { color: var(--accent-2); }
|
||
.line.warn .lvl { color: var(--warn); }
|
||
.line.warn { border-left-color: var(--warn); background: oklch(0.7 0.18 35 / 0.04); }
|
||
.line.err .lvl { color: var(--bad); }
|
||
.line.err { border-left-color: var(--bad); background: oklch(0.65 0.22 25 / 0.05); }
|
||
.line.dbg .lvl { color: var(--ink-3); }
|
||
.line.ok .lvl { color: var(--ok); }
|
||
.msg { color: var(--ink); white-space: pre-wrap; word-break: break-word; }
|
||
|
||
.input {
|
||
display: flex; align-items: center;
|
||
border-top: 1px solid var(--line);
|
||
background: var(--bg-0);
|
||
padding: 0 10px;
|
||
height: 32px; gap: 8px;
|
||
}
|
||
.prompt { color: var(--accent); font-family: var(--mono); font-size: 12px; }
|
||
input[type="text"] {
|
||
flex: 1; background: transparent; border: none; outline: none;
|
||
color: var(--ink); font-family: var(--mono); font-size: 12px;
|
||
height: 100%;
|
||
}
|
||
input::placeholder { color: var(--ink-4); }
|
||
`;
|
||
|
||
override connectedCallback(): void {
|
||
super.connectedCallback();
|
||
effect(() => {
|
||
consoleLines.value; consoleFilter.value; consolePaused.value;
|
||
this.requestUpdate();
|
||
});
|
||
}
|
||
|
||
override updated(): void {
|
||
const body = this.renderRoot.querySelector('.body') as HTMLElement | null;
|
||
if (body) body.scrollTop = body.scrollHeight;
|
||
}
|
||
|
||
private counts(): Record<string, number> {
|
||
const c: Record<string, number> = { info: 0, warn: 0, err: 0, dbg: 0, ok: 0 };
|
||
for (const l of consoleLines.value) c[l.level] = (c[l.level] ?? 0) + 1;
|
||
c.all = consoleLines.value.length;
|
||
return c;
|
||
}
|
||
|
||
private async exec(line: string): Promise<void> {
|
||
line = line.trim();
|
||
if (!line) return;
|
||
pushLog('info', `<span style="color:var(--accent);">nvsim></span> ${line}`);
|
||
this.history.push(line); this.hIdx = this.history.length;
|
||
const [cmd, ...args] = line.split(/\s+/);
|
||
const arg = args.join(' ');
|
||
const c = getClient();
|
||
switch (cmd) {
|
||
case 'help':
|
||
pushLog('info', 'commands: help · scene.list · sensor.config · run · pause · seed · proof.verify · clear · theme · status');
|
||
break;
|
||
case 'scene.list':
|
||
pushLog('info', 'scene rebar-walkby-01:');
|
||
pushLog('info', ' rebar.steel.coil @ [+2.7, 0.0, +0.3] m χ=5000');
|
||
pushLog('info', ' dipole.heart_proxy @ [-1.4, +0.2, +0.4] m m=1.0e-6 A·m²');
|
||
pushLog('info', ' loop.mains_60Hz @ [-1.6, -0.4, 0.0] m I=2 A');
|
||
pushLog('info', ' eddy.door_steel @ [+0.0, +1.8, +0.4] m σ=1e6 S/m');
|
||
break;
|
||
case 'sensor.config':
|
||
pushLog('info', 'NvSensor::cots_defaults() {');
|
||
pushLog('info', ' pos=[0,0,0], V=1mm³, N=1e12, C=0.03, T2*=200ns');
|
||
pushLog('info', ' D=2.870 GHz, γe=28 GHz/T, Γ=1.0 MHz, axes=4×〈111〉');
|
||
pushLog('info', ' δB ≈ 1.18 pT/√Hz (Barry 2020 §III.A) }');
|
||
break;
|
||
case 'run':
|
||
if (c) { await c.run(); running.value = true; pushLog('ok', 'pipeline RUN'); }
|
||
break;
|
||
case 'pause':
|
||
if (c) { await c.pause(); running.value = false; pushLog('warn', 'pipeline PAUSED'); }
|
||
break;
|
||
case 'reset':
|
||
if (c) { await c.reset(); pushLog('info', 'pipeline reset · t=0'); }
|
||
break;
|
||
case 'seed': {
|
||
if (!arg) { pushLog('info', `current seed = 0x${seed.value.toString(16).toUpperCase()}`); break; }
|
||
const v = BigInt(arg.startsWith('0x') ? arg : '0x' + arg);
|
||
seed.value = v;
|
||
if (c) await c.setSeed(v);
|
||
pushLog('ok', `seed → 0x${v.toString(16).toUpperCase()}`);
|
||
break;
|
||
}
|
||
case 'proof.verify': {
|
||
if (!c) break;
|
||
pushLog('dbg', 'computing SHA-256 over 256 frames…');
|
||
try {
|
||
const exp = expectedWitness.value;
|
||
const expBytes = new Uint8Array(32);
|
||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||
const r = await c.verifyWitness(expBytes);
|
||
if (r.ok) { witnessVerified.value = 'ok'; witnessHex.value = exp; pushLog('ok', `witness ${exp.slice(0, 16)}… matches · determinism gate ✓`); }
|
||
else { witnessVerified.value = 'fail'; pushLog('err', 'WITNESS MISMATCH'); }
|
||
} catch (e) { pushLog('err', `verify failed: ${(e as Error).message}`); }
|
||
break;
|
||
}
|
||
case 'clear':
|
||
consoleLines.value = [];
|
||
break;
|
||
case 'theme': {
|
||
const t = (arg || '').toLowerCase();
|
||
if (t === 'light' || t === 'dark') { theme.value = t; pushLog('ok', `theme → ${t}`); }
|
||
else pushLog('info', 'theme [light|dark]');
|
||
break;
|
||
}
|
||
case 'status':
|
||
pushLog('info', `running=${running.value} seed=0x${seed.value.toString(16).toUpperCase()} verified=${witnessVerified.value}`);
|
||
break;
|
||
default:
|
||
pushLog('err', `unknown command: ${cmd} · try help`);
|
||
}
|
||
}
|
||
|
||
private onKey = (e: KeyboardEvent): void => {
|
||
if (e.key === 'Enter') { void this.exec(this.inputEl.value); this.inputEl.value = ''; }
|
||
else if (e.key === 'ArrowUp') {
|
||
if (this.history.length) {
|
||
this.hIdx = Math.max(0, this.hIdx - 1);
|
||
this.inputEl.value = this.history[this.hIdx] ?? '';
|
||
e.preventDefault();
|
||
}
|
||
} else if (e.key === 'ArrowDown') {
|
||
if (this.history.length) {
|
||
this.hIdx = Math.min(this.history.length, this.hIdx + 1);
|
||
this.inputEl.value = this.history[this.hIdx] ?? '';
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
};
|
||
|
||
override render() {
|
||
const c = this.counts();
|
||
const filter = consoleFilter.value;
|
||
const visible = consoleLines.value.filter((l) => filter === 'all' || l.level === filter);
|
||
return html`
|
||
<div class="tabs">
|
||
${(['all', 'info', 'warn', 'err', 'dbg'] as const).map((k) => html`
|
||
<button class="tab ${filter === k ? 'active' : ''}" data-tab=${k}
|
||
@click=${() => consoleFilter.value = k}>
|
||
${k} <span class="cnt">${c[k] ?? 0}</span>
|
||
</button>
|
||
`)}
|
||
<span class="spacer"></span>
|
||
<div class="tools">
|
||
<button id="clear-log" title="Clear" @click=${() => consoleLines.value = []}>×</button>
|
||
<button id="pause-log" title="Pause" @click=${() => consolePaused.value = !consolePaused.value}>
|
||
${consolePaused.value ? '▶' : '❚❚'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="body">
|
||
${visible.map((l) => {
|
||
const ts = new Date(l.ts);
|
||
const tsStr = `${String(ts.getSeconds()).padStart(2, '0')}.${String(ts.getMilliseconds()).padStart(3, '0')}`;
|
||
// Use innerHTML pass-through via unsafe-html alt: inject raw html via property
|
||
return html`<div class="line ${l.level}">
|
||
<div class="ts">${tsStr}</div>
|
||
<div class="lvl">${l.level}</div>
|
||
<div class="msg" .innerHTML=${l.msg}></div>
|
||
</div>`;
|
||
})}
|
||
</div>
|
||
<div class="input">
|
||
<span class="prompt">nvsim></span>
|
||
<input id="console-input" type="text"
|
||
placeholder="help · scene.list · sensor.config · run · proof.verify · clear"
|
||
@keydown=${this.onKey}/>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|