feat(ui): mock simulator fallback when the Rust backend is offline

On static hosts (GitHub Pages) and when the Rust backend isn't running,
the raster was going blank because the old Web-Worker mock simulator
had been deleted in favour of the SSE-only path. Reinstated it as a
fallback only:

- dynamics.js: status probe + SSE handshake keep their "real data"
  behaviour when the Rust backend is up.
- If /api/status flags 'down' OR the EventSource errors before ever
  receiving a 'hello', the old JS mock Worker is instantiated from
  the workerSrc string that was already in the file. Both sources
  feed the same writeTick() function, so the raster / Fiedler / banner
  render identically regardless of who's driving them.
- Banner flips to a new 'mock' state: "no backend — showing JS mock
  (run ui_server for real data)". window.Dynamics.isMock() returns
  true so anything else in the UI can check the source.
- layout.css: amber 'mock' state for the banner colour.
- window.Dynamics.setScenario / setHealth / pause / play now forward
  to the mock worker when it exists (scenarios actually work on the
  fallback); they're silent no-ops on the real backend where the
  server picks the stimulus.

Verified via agent-browser with the Rust backend killed:
banner="no backend — showing JS mock …", state="mock", isMock=true,
tick advances into the hundreds, Fiedler animates, zero console
errors. With the backend running the original real-data path still
fires (hello event → sseReady → no fallback).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruvnet 2026-04-23 00:16:19 -04:00
parent 65f290888e
commit cdcb2bb8d1
2 changed files with 153 additions and 78 deletions

View file

@ -177,27 +177,38 @@
}, 40);
`;
// NOTE: the workerSrc string above is retained as documentation of
// the previous mock simulator. It is NOT instantiated — the worker
// variable below is a small shim that ignores setScenario calls so
// the scenario pills in the header don't throw. Every spike, every
// Fiedler value rendered by this module now comes from /api/stream.
const worker = {
postMessage: (msg) => {
// Scenario control is a no-op on the real backend (server
// chooses its own stimulus). Log to console so a developer
// poking the pills sees that it didn't silently eat the call.
// eslint-disable-next-line no-console
console.debug('[CONNECTOME-OS REAL] worker.postMessage ignored (real backend):', msg);
},
};
// Reference suppresses the "unused worker" linter and documents
// the shim. Kept for diff clarity.
void worker;
// Reference the workerSrc template literal so the old simulator
// code stays in the bundle as reference — but the string is never
// `eval`'d, never loaded into a Worker, never touched at runtime.
void workerSrc;
// A live mock-Worker reference, populated by startMockSimulator() when
// the real Rust backend is unreachable (e.g. on GitHub Pages). When a
// real backend is connected, `worker` stays null and the mock never
// runs — SSE is the source of truth.
let worker = null;
let usingMock = false;
function startMockSimulator() {
if (worker) return;
const blob = new Blob([workerSrc], { type: 'application/javascript' });
worker = new Worker(URL.createObjectURL(blob));
usingMock = true;
worker.onmessage = (e) => {
const { spikes, fiedler, tick: mtick } = e.data;
writeTick({
spikes,
fiedler,
tick: mtick,
totalSpikesDelta: spikes.length,
source: 'mock',
});
};
// eslint-disable-next-line no-console
console.warn(
'[CONNECTOME-OS] no Rust backend — the raster is now driven by the built-in JS mock simulator. Run `cargo run --release --bin ui_server` to switch to real data.'
);
const banner = document.getElementById('real-backend-banner');
if (banner) {
banner.textContent = 'no backend — showing JS mock (run ui_server for real data)';
banner.dataset.state = 'mock';
}
}
// === Raster rendering ==================================================
const raster = document.getElementById('raster-canvas');
@ -361,45 +372,29 @@
mfctx.shadowBlur = 0;
}
// === Receive spikes from REAL rust-lif backend (SSE) ===================
// Each 'tick' event from /api/stream carries real spike ids. Map
// each id (in [0, num_neurons)) into the raster's row-space
// ([0, ROWS=208)) via modulo. The Fiedler value is the live
// Observer.latest_fiedler() — `λ₂` of the co-firing Laplacian
// computed by the Rust eigensolver.
// === Shared per-tick writer ===========================================
// Both the real SSE stream and the JS mock Worker feed writeTick().
// One code path renders the raster + Fiedler regardless of source.
let spikeBudget = 0;
let sawRealTick = false;
let realTickCount = 0;
let realTotalSpikes = 0;
const es = new EventSource(REAL_STREAM_URL);
es.addEventListener('hello', (ev) => {
try {
const h = JSON.parse(ev.data);
// eslint-disable-next-line no-console
console.info(
`[CONNECTOME-OS REAL] /api/stream hello → engine=${h.engine} crate=${h.crate} n=${h.connectome.n} m=${h.connectome.m} witness=${h.witness}`
);
} catch (_) {}
});
es.addEventListener('tick', (ev) => {
let d;
try { d = JSON.parse(ev.data); } catch (_) { return; }
const spikes = d.spikes || [];
function writeTick({ spikes, fiedler, tick: tickNum, totalSpikesDelta, source }) {
const arr = spikes || [];
const colBase = col * ROWS;
for (let i = 0; i < ROWS; i++) buffer[colBase + i] = 0;
for (let s = 0; s < spikes.length; s++) {
const row = spikes[s] % ROWS;
for (let s = 0; s < arr.length; s++) {
const row = arr[s] % ROWS;
buffer[colBase + row] = 1;
}
col = (col + 1) % COLS;
spikeBudget += spikes.length;
spikeBudget += arr.length;
realTickCount += 1;
realTotalSpikes = d.n_spikes_total || realTotalSpikes;
realTotalSpikes += totalSpikesDelta || 0;
// Fiedler: server sends null while the detector warms up. Keep
// the last known value in that case so the plot doesn't jitter.
if (d.fiedler !== null && !Number.isNaN(d.fiedler)) {
fVal = d.fiedler;
if (fiedler !== null && fiedler !== undefined && !Number.isNaN(fiedler)) {
fVal = fiedler;
}
fHist[fHead] = fVal;
fHead = (fHead + 1) % FHIST;
@ -410,42 +405,109 @@
}
if (fVal > 0.25) fiedlerAlerted = false;
// Expose to UI + debugging globals.
window._fiedler = fVal;
window._tick = d.tick;
window._sim_ms = d.t;
window._tick = tickNum;
window._real_spikes_total = realTotalSpikes;
window._source = source; // 'real' or 'mock'
if (!sawRealTick) {
if (source === 'real' && !sawRealTick) {
sawRealTick = true;
// eslint-disable-next-line no-console
console.info(
`[CONNECTOME-OS REAL] first tick received — t=${d.t}ms tick=${d.tick} spikes_this_tick=${spikes.length} n_spikes_total=${realTotalSpikes}`
`[CONNECTOME-OS REAL] first tick received — tick=${tickNum} spikes_this_tick=${arr.length} n_spikes_total=${realTotalSpikes}`
);
}
// Heartbeat every 200 ticks (~200 ms of real time) proving the
// stream is live and carrying real counts.
if (realTickCount % 200 === 0) {
if (source === 'real' && realTickCount % 200 === 0) {
// eslint-disable-next-line no-console
console.info(
`[CONNECTOME-OS REAL] live: tick=${d.tick} n_spikes_total=${realTotalSpikes} fiedler=${fVal.toFixed(4)}`
`[CONNECTOME-OS REAL] live: tick=${tickNum} n_spikes_total=${realTotalSpikes} fiedler=${fVal.toFixed(4)}`
);
}
});
es.addEventListener('communities', (ev) => {
try {
const c = JSON.parse(ev.data);
window._communities_latest = c;
// eslint-disable-next-line no-console
console.info(
`[CONNECTOME-OS REAL] community snapshot tick=${c.tick} num_communities=${c.num_communities} module_sample=${c.module_sample}`
);
} catch (_) {}
});
es.onerror = (e) => {
// eslint-disable-next-line no-console
console.warn('[CONNECTOME-OS] /api/stream error — EventSource will auto-reconnect if possible.', e);
};
}
// === Receive spikes from REAL rust-lif backend (SSE) ===================
// Falls back to the mock Worker if the stream errors (e.g. static
// hosting like GitHub Pages where there's no Rust process).
let es = null;
let sseReady = false;
function startRealStream() {
es = new EventSource(REAL_STREAM_URL);
es.addEventListener('hello', (ev) => {
sseReady = true;
try {
const h = JSON.parse(ev.data);
// eslint-disable-next-line no-console
console.info(
`[CONNECTOME-OS REAL] /api/stream hello → engine=${h.engine} crate=${h.crate} n=${h.connectome.n} m=${h.connectome.m} witness=${h.witness}`
);
} catch (_) {}
});
es.addEventListener('tick', (ev) => {
let d;
try { d = JSON.parse(ev.data); } catch (_) { return; }
const delta =
typeof d.n_spikes_total === 'number'
? Math.max(0, d.n_spikes_total - realTotalSpikes)
: (d.spikes ? d.spikes.length : 0);
// Assign the absolute total rather than accumulating the delta
// here, so the console log matches the server's counter exactly.
realTotalSpikes = d.n_spikes_total || realTotalSpikes;
writeTick({
spikes: d.spikes,
fiedler: d.fiedler,
tick: d.tick,
totalSpikesDelta: 0, // total is reassigned above
source: 'real',
});
// One extra side effect: expose sim_ms for the real path.
window._sim_ms = d.t;
void delta;
});
es.addEventListener('communities', (ev) => {
try {
const c = JSON.parse(ev.data);
window._communities_latest = c;
// eslint-disable-next-line no-console
console.info(
`[CONNECTOME-OS REAL] community snapshot tick=${c.tick} num_communities=${c.num_communities} module_sample=${c.module_sample}`
);
} catch (_) {}
});
es.onerror = () => {
if (sseReady) {
// Transient drop after a successful hello — EventSource will
// reconnect on its own. Don't fall back to the mock because
// real data may be resumed shortly.
// eslint-disable-next-line no-console
console.warn('[CONNECTOME-OS] /api/stream drop — EventSource will auto-reconnect.');
return;
}
// Never saw a hello → no Rust backend. Close and fall back.
try { es.close(); } catch (_) {}
es = null;
if (!usingMock) startMockSimulator();
};
}
// Kick off the real stream. If status probe above already flagged the
// backend as 'down', start the mock immediately instead.
const banner = document.getElementById('real-backend-banner');
if (banner && banner.dataset.state === 'down') {
startMockSimulator();
} else {
startRealStream();
// Safety net: if we never get a hello within 4 s, the backend is
// almost certainly unreachable (e.g. static hosting). Start the
// mock so the raster isn't blank.
setTimeout(() => {
if (!sseReady && !usingMock) {
try { if (es) es.close(); } catch (_) {}
es = null;
startMockSimulator();
}
}, 4000);
}
// === UI render tick ====================================================
let spikesPerSec = 0;
@ -486,11 +548,23 @@
requestAnimationFrame(uiTick);
// === Public API ========================================================
// The scenario / health / pause controls only apply to the JS mock
// simulator. On the real backend the server chooses the stimulus;
// these calls are no-ops but we log so it's visible in DevTools.
function sendWorker(msg) {
if (worker) {
worker.postMessage(msg);
} else {
// eslint-disable-next-line no-console
console.debug('[CONNECTOME-OS REAL] worker control ignored (real backend):', msg);
}
}
window.Dynamics = {
setScenario(name) { worker.postMessage({ type: 'setScenario', scenario: name }); },
setHealth(arr) { worker.postMessage({ type: 'setHealth', health: arr }); },
pause() { worker.postMessage({ type: 'pause' }); },
play() { worker.postMessage({ type: 'play' }); },
getFiedler() { return fVal; }
setScenario(name) { sendWorker({ type: 'setScenario', scenario: name }); },
setHealth(arr) { sendWorker({ type: 'setHealth', health: arr }); },
pause() { sendWorker({ type: 'pause' }); },
play() { sendWorker({ type: 'play' }); },
getFiedler() { return fVal; },
isMock() { return usingMock; },
};
})();

View file

@ -101,6 +101,7 @@
#real-backend-banner[data-state="pending"] { color: var(--amber); }
#real-backend-banner[data-state="live"] { color: var(--signal); }
#real-backend-banner[data-state="down"] { color: #ff6b6b; }
#real-backend-banner[data-state="mock"] { color: var(--amber); }
/* === LEFT RAIL === */
.rail {