mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-19 11:04:43 +00:00
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:
parent
db5a898372
commit
fbecae4e02
7 changed files with 290 additions and 16 deletions
21
ui/app.js
21
ui/app.js
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
131
ui/utils/data-source-banner.js
Normal file
131
ui/utils/data-source-banner.js
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue