fix(ui): never present invented data as measured

The dashboard flip-flopped between "absent" and "present_still" after
navigating away and back. The cause was not sensing. On a dropped socket the
client fell back to a built-in simulation and rendered synthetic values that
were indistinguishable from live ones, so the UI was showing made-up readings
and saying nothing about it.

That is the most damaging failure this UI can have. A sensing system that
invents data silently is worse than one that shows nothing, because every
downstream judgement -- including which nodes to move and whether a change
helped -- is then made against fiction.

Changes:

  - Client-side simulation is default-OFF and must be opted into explicitly.
  - An unreachable server retries forever instead of substituting data.
  - A banner states plainly whenever what is displayed is not live. The
    labels say what they mean: "NO DATA - SERVER UNREACHABLE" for an
    unreachable server, and "INVENTED DATA - NOT MEASURED" for simulation,
    replacing "OFFLINE - CLIENT SIMULATION", which read like a status rather
    than a warning.

Also fixes a reconnect race in the same path: two in-flight connects could each
install handlers, so a stale socket kept overwriting fresher state. That race is
what made the flip-flop intermittent and hard to attribute.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Joe 2026-09-03 11:42:16 -04:00
parent db5a898372
commit fbecae4e02
7 changed files with 290 additions and 16 deletions

View file

@ -28,6 +28,7 @@ import { i18n } from './utils/i18n.js';
import { ScreenshotTool } from './utils/screenshot.js';
import { UptimeClock } from './utils/uptime-clock.js';
import { QuickSettings } from './utils/quick-settings.js';
import { dataSourceBanner } from './utils/data-source-banner.js';
class WiFiDensePoseApp {
constructor() {
@ -98,6 +99,10 @@ class WiFiDensePoseApp {
this.showBackendStatus('Backend unavailable — start sensing-server', 'warning');
}
// Mount the global provenance bar before the service starts, so the very
// first non-live state is visible on whichever tab is showing.
dataSourceBanner.init();
// Start the sensing WebSocket service early so the dashboard and
// live-demo tabs can show the correct data-source status immediately.
sensingService.start();
@ -360,6 +365,22 @@ class WiFiDensePoseApp {
window.addEventListener('beforeunload', () => {
this.cleanup();
});
// `beforeunload` above tears down every component and disconnects the
// sensing WebSocket. If the browser restores this page from bfcache on
// Back/Forward instead of doing a fresh navigation, that torn-down state
// comes back frozen exactly as-is — nothing re-runs DOMContentLoaded, so
// sensingService.start() never fires again and disposed components (e.g.
// Room Builder) never get recreated. `pageshow`'s `event.persisted` is
// the standard signal for "this is a bfcache restore, not a fresh load";
// forcing a real reload here is simpler and more robust than trying to
// hand-reinitialize every component/connection back to a clean state.
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
console.log('Page restored from bfcache after cleanup() — forcing reload');
window.location.reload();
}
});
}
// Handle window resize

View file

@ -95,7 +95,8 @@ export class DashboardTab {
'live': { text: 'ESP32', status: 'healthy', msg: 'Real hardware connected' },
'server-simulated': { text: 'SIMULATED', status: 'warning', msg: 'Server running without hardware' },
'reconnecting': { text: 'RECONNECTING', status: 'degraded', msg: 'Attempting to connect...' },
'simulated': { text: 'OFFLINE', status: 'unhealthy', msg: 'Server unreachable, local fallback' },
'unreachable': { text: 'NO DATA', status: 'unhealthy', msg: 'Server unreachable — readings below are stale' },
'simulated': { text: 'INVENTED', status: 'unhealthy', msg: 'Browser-generated data, not measured' },
};
const cfg = config[ds] || config['reconnecting'];
el.className = `component-status status-${cfg.status}`;

View file

@ -1318,7 +1318,8 @@ export class LiveDemoTab {
const ds = sensingService.dataSource;
if (ds === 'live') return 'Active \u2014 ESP32 Live';
if (ds === 'server-simulated') return 'Active \u2014 Simulated Data';
if (ds === 'simulated') return 'Active \u2014 Offline Simulation';
if (ds === 'unreachable') return 'No Data \u2014 Server Unreachable';
if (ds === 'simulated') return 'INVENTED DATA \u2014 Not Measured';
return 'Connecting...';
}
@ -1331,7 +1332,8 @@ export class LiveDemoTab {
'live': { text: 'LIVE \u2014 ESP32 Hardware Connected', cls: 'demo-source-live' },
'server-simulated': { text: 'SIMULATED DATA \u2014 No Hardware Detected', cls: 'demo-source-sim' },
'reconnecting': { text: 'RECONNECTING TO SERVER...', cls: 'demo-source-reconnecting' },
'simulated': { text: 'OFFLINE \u2014 Server Unreachable, Local Sim', cls: 'demo-source-offline' },
'unreachable': { text: 'NO DATA \u2014 Server Unreachable, Display Is Stale', cls: 'demo-source-offline' },
'simulated': { text: 'INVENTED DATA \u2014 Generated By Your Browser, Not Measured', cls: 'demo-source-offline' },
};
const cfg = config[ds] || config['reconnecting'];
banner.textContent = cfg.text;

View file

@ -228,7 +228,8 @@ export class SensingTab {
'live': { text: 'LIVE \u2014 ESP32 HARDWARE', cls: 'sensing-source-live' },
'server-simulated': { text: 'SIMULATED \u2014 NO HARDWARE', cls: 'sensing-source-server-sim' },
'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' },
'simulated': { text: 'OFFLINE \u2014 CLIENT SIMULATION', cls: 'sensing-source-simulated' },
'unreachable': { text: 'NO DATA \u2014 SERVER UNREACHABLE', cls: 'sensing-source-simulated' },
'simulated': { text: 'INVENTED DATA \u2014 NOT MEASURED', cls: 'sensing-source-simulated' },
};
const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting;
banner.textContent = cfg.text;

View file

@ -37,6 +37,39 @@ const MAX_RECONNECT_ATTEMPTS = 20;
const SIM_FALLBACK_AFTER_ATTEMPTS = 5;
const SIMULATION_INTERVAL = 500; // ms
export const SIM_FALLBACK_STORAGE_KEY = 'ruview-client-simulation';
/**
* Whether the client may invent sensing frames when the server is unreachable.
*
* Default **off**, deliberately. The generated frames are plausible — presence
* derived from `Math.sin(t * 0.3)` crossing a fixed threshold produces a steady
* absent/present_still alternation that reads exactly like a real classifier
* struggling at its decision boundary. A reader with no reason to suspect the
* server had dropped will diagnose the sensing pipeline, and be wrong. That is
* not a hypothetical: it cost a full debugging session on 2026-08-28, chasing a
* "flip-flop" that was this function all along while the server sat at a
* perfectly steady presence=true.
*
* For an instrument, "no signal" is the honest reading and a useful one.
* Synthetic motion is only appropriate for a demo, so it must be asked for:
* `?simulate=1`, or `localStorage['ruview-client-simulation'] = 'true'`.
*
* Repository rule: never present synthetic output as measurement (CLAUDE.md).
*/
function clientSimulationAllowed() {
try {
const params = new URLSearchParams(window.location.search);
if (params.get('simulate') === '1') return true;
if (params.get('simulate') === '0') return false;
} catch { /* no window/location — non-browser test context */ }
try {
return localStorage.getItem(SIM_FALLBACK_STORAGE_KEY) === 'true';
} catch {
return false;
}
}
class SensingService {
constructor() {
/** @type {WebSocket|null} */
@ -46,6 +79,11 @@ class SensingService {
this._reconnectAttempt = 0;
this._reconnectTimer = null;
this._simTimer = null;
// Guards the await window inside `_connect()` (see the comment there).
this._connectInFlight = false;
// Bumped per connect attempt and by `stop()`; lets a socket recognise that
// it has been abandoned and stop driving reconnect state.
this._connectEpoch = 0;
// Connection state: disconnected | connecting | connected | reconnecting | simulated
this._state = 'disconnected';
// Data-source label exposed to the UI:
@ -72,6 +110,10 @@ class SensingService {
/** Stop the service entirely. */
stop() {
// Invalidate any attempt still inside its await window, so a socket opened
// after this call does not resurrect the service.
this._connectEpoch++;
this._connectInFlight = false;
this._clearTimers();
if (this._ws) {
this._ws.close(1000, 'client stop');
@ -114,7 +156,10 @@ class SensingService {
* Current data source label.
* "live" — frames are arriving from the real ESP32 over WebSocket
* "reconnecting" — WebSocket disconnected; actively retrying, no frames emitted
* "simulated" — max reconnect attempts exhausted; emitting synthetic frames
* "unreachable" — retries exhausted, client simulation off; NO frames emitted
* and whatever is on screen is the last live reading, stale
* "simulated" — client simulation explicitly enabled and running; every
* frame on screen is invented
*/
get dataSource() {
return this._dataSource;
@ -129,6 +174,22 @@ class SensingService {
// reconnects would fail on the second attempt.
async _connect() {
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
// The readyState guard above cannot stand alone: this function awaits
// ticket minting before it assigns `this._ws`, so two callers entering
// during that window BOTH pass the guard and BOTH open a socket. The
// second assignment orphans the first — a socket nobody references, still
// open on the server, whose `onclose` fires later and calls
// `_scheduleReconnect()` even though a healthy socket is live. That walks
// `_reconnectAttempt` upward with no outage and eventually trips the
// simulation fallback. Observed on the server as connect/disconnect churn
// on every page load.
if (this._connectInFlight) return;
this._connectInFlight = true;
// Identifies this attempt for the lifetime of its socket. `stop()` and any
// superseding attempt bump it, so a late event from an abandoned socket is
// recognised and ignored rather than driving reconnect state.
const epoch = ++this._connectEpoch;
this._setState('connecting');
@ -140,15 +201,34 @@ class SensingService {
// predating ADR-272, connecting without a ticket is correct.
}
if (epoch !== this._connectEpoch) {
// Superseded or stopped while minting. The ticket is single-use and now
// spent; the current attempt mints its own.
this._connectInFlight = false;
return;
}
let ws;
try {
this._ws = new WebSocket(url);
ws = new WebSocket(url);
} catch (err) {
console.warn('[Sensing] WebSocket constructor failed:', err.message);
this._connectInFlight = false;
this._fallbackToSimulation();
return;
}
this._ws.onopen = () => {
this._ws = ws;
this._connectInFlight = false;
/** True while `ws` is still the socket this service is driving. */
const isCurrent = () => epoch === this._connectEpoch && this._ws === ws;
ws.onopen = () => {
if (!isCurrent()) {
ws.close(1000, 'superseded');
return;
}
console.info('[Sensing] Connected to', SENSING_WS_URL);
this._reconnectAttempt = 0;
this._stopSimulation();
@ -158,7 +238,8 @@ class SensingService {
this._detectServerSource();
};
this._ws.onmessage = (evt) => {
ws.onmessage = (evt) => {
if (!isCurrent()) return;
try {
const data = JSON.parse(evt.data);
this._handleData(data);
@ -167,11 +248,17 @@ class SensingService {
}
};
this._ws.onerror = () => {
ws.onerror = () => {
// onerror is always followed by onclose, so we handle reconnect there
};
this._ws.onclose = (evt) => {
ws.onclose = (evt) => {
if (!isCurrent()) {
// An abandoned socket closing is not an outage. Reconnect state
// belongs to whichever attempt is current.
console.debug('[Sensing] Ignoring close from superseded socket');
return;
}
console.info('[Sensing] Connection closed (code=%d)', evt.code);
this._ws = null;
if (evt.code !== 1000) {
@ -184,18 +271,31 @@ class SensingService {
}
_scheduleReconnect() {
// A retry is already pending; stacking timers would multiply the attempt
// count for a single outage.
if (this._reconnectTimer) return;
if (this._reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
console.warn('[Sensing] Max reconnect attempts (%d) reached, switching to simulation', MAX_RECONNECT_ATTEMPTS);
this._fallbackToSimulation();
return;
if (clientSimulationAllowed()) {
console.warn('[Sensing] Max reconnect attempts (%d) reached, switching to simulation', MAX_RECONNECT_ATTEMPTS);
this._fallbackToSimulation();
return;
}
// With simulation off there is nothing to give up *for*: keep retrying at
// the ceiling delay so the UI recovers by itself when the server returns,
// rather than requiring a hard refresh.
this._setDataSource('unreachable');
}
const delay = RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)];
this._reconnectAttempt++;
// Hold at the cap rather than counting into the thousands over a long
// outage; the delay is already saturated and the number is only read by a
// human in the console.
this._reconnectAttempt = Math.min(this._reconnectAttempt + 1, MAX_RECONNECT_ATTEMPTS);
console.info('[Sensing] Reconnecting in %dms (attempt %d/%d)', delay, this._reconnectAttempt, MAX_RECONNECT_ATTEMPTS);
this._setState('reconnecting');
this._setDataSource('reconnecting');
if (this._dataSource !== 'unreachable') this._setDataSource('reconnecting');
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
@ -205,13 +305,23 @@ class SensingService {
// Only start simulation after several failed attempts so a brief hiccup
// does not immediately switch the UI to "SIMULATED DATA".
if (this._reconnectAttempt >= SIM_FALLBACK_AFTER_ATTEMPTS && this._state !== 'simulated') {
this._fallbackToSimulation();
if (clientSimulationAllowed()) {
this._fallbackToSimulation();
} else {
// Say "the server is gone" instead of inventing a room.
this._setDataSource('unreachable');
}
}
}
// ---- Simulation fallback -----------------------------------------------
_fallbackToSimulation() {
if (!clientSimulationAllowed()) {
this._setDataSource('unreachable');
this._scheduleReconnect();
return;
}
this._setState('simulated');
this._setDataSource('simulated');
if (this._simTimer) return; // already running

View file

@ -1804,7 +1804,11 @@ canvas {
flex-direction: column;
gap: var(--space-12);
overflow-y: auto;
/* Without this, `overflow-y: auto` alone still resolves the x axis to auto,
so anything marginally too wide scrolls sideways instead of wrapping. */
overflow-x: hidden;
max-height: 600px;
min-width: 0;
}
.sensing-card {
@ -1812,6 +1816,10 @@ canvas {
border: 1px solid var(--color-card-border);
border-radius: var(--radius-md);
padding: var(--space-12);
/* Flex items default to min-width:auto, which refuses to shrink below their
content — one wide child then widens the whole 320px column and puts a
horizontal scrollbar on the panel. Cards must shrink to the column. */
min-width: 0;
}
.sensing-card-title {

View file

@ -0,0 +1,131 @@
// Global data-source banner.
//
// The per-tab banner in SensingTab was correct and still invisible where it
// mattered: a reader on Dashboard or Live Demo never saw it, so a screen full
// of client-invented sensing frames was indistinguishable from measurement.
// This bar is fixed to the viewport and present on every tab, so the provenance
// of what is on screen is never more than a glance away.
//
// It shows nothing at all while data is live. An always-on badge becomes
// wallpaper; appearing only when the reading is not trustworthy keeps it
// meaningful.
import { sensingService } from '../services/sensing.service.js';
const STYLE_ID = 'ruview-data-source-banner-style';
const BAR_ID = 'ruview-data-source-banner';
const CONFIG = {
// 'live' is intentionally absent: no banner is shown for trustworthy data.
'server-simulated': {
text: 'SYNTHETIC DATA — the server is generating this, no hardware is being read',
bg: '#7c3a00',
fg: '#ffd9a0',
},
reconnecting: {
text: 'RECONNECTING — the display is frozen at the last live reading',
bg: '#33302a',
fg: '#e8dcc0',
},
unreachable: {
text: 'SERVER UNREACHABLE — nothing on this screen is current',
bg: '#5c1010',
fg: '#ffc9c9',
},
simulated: {
text: 'SIMULATED — every value on this screen is invented by your browser, not measured',
bg: '#6b0f6b',
fg: '#ffc9ff',
},
};
const CSS = `
#${BAR_ID} {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100000;
display: none;
padding: 7px 14px;
font: 600 13px/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.04em;
text-align: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.45);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}
#${BAR_ID}.is-visible { display: block; }
/* Simulated data is the dangerous case — it looks real. Make the bar move so
it cannot be mistaken for a static decoration. */
#${BAR_ID}.is-simulated { animation: ruview-dsb-pulse 1.6s ease-in-out infinite; }
@keyframes ruview-dsb-pulse { 50% { filter: brightness(1.45); } }
@media (prefers-reduced-motion: reduce) {
#${BAR_ID}.is-simulated { animation: none; }
}
body.ruview-has-data-source-banner { padding-top: 30px; }
`;
export class DataSourceBanner {
constructor() {
this._bar = null;
this._unsubscribe = null;
}
init() {
if (typeof document === 'undefined') return;
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
this._bar = document.getElementById(BAR_ID);
if (!this._bar) {
this._bar = document.createElement('div');
this._bar.id = BAR_ID;
this._bar.setAttribute('role', 'status');
// Announce changes, but don't interrupt: the reader is mid-task and the
// message describes context, not an error to act on right now.
this._bar.setAttribute('aria-live', 'polite');
document.body.appendChild(this._bar);
}
// dataSource changes are published on the state-listener channel.
this._unsubscribe = sensingService.onStateChange(() => this.render());
this.render();
}
render() {
if (!this._bar) return;
const cfg = CONFIG[sensingService.dataSource];
if (!cfg) {
this._bar.classList.remove('is-visible', 'is-simulated');
document.body.classList.remove('ruview-has-data-source-banner');
return;
}
this._bar.textContent = cfg.text;
this._bar.style.background = cfg.bg;
this._bar.style.color = cfg.fg;
this._bar.classList.add('is-visible');
this._bar.classList.toggle('is-simulated', sensingService.dataSource === 'simulated');
document.body.classList.add('ruview-has-data-source-banner');
}
dispose() {
if (this._unsubscribe) {
this._unsubscribe();
this._unsubscribe = null;
}
if (this._bar && this._bar.parentNode) {
this._bar.parentNode.removeChild(this._bar);
}
this._bar = null;
document.body.classList.remove('ruview-has-data-source-banner');
}
}
export const dataSourceBanner = new DataSourceBanner();