mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
Merge PR #2128 (aepod) into release integration
# Conflicts: # .github/workflows/ci.yml
This commit is contained in:
commit
fd323e483e
12 files changed
+392
-19
No files matched your search
@@ -204,7 +204,7 @@ jobs:
|
||||
node-version: '22'
|
||||
|
||||
- name: Run UI unit tests
|
||||
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs ui/components/DashboardTab.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
|
||||
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs ui/services/sensing.service.test.mjs ui/components/DashboardTab.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
|
||||
|
||||
# Unit and Integration Tests
|
||||
# Python pytest matrix — runs against the archived v1 Python tree.
|
||||
|
||||
+2
-1
@@ -82,13 +82,14 @@ ui/
|
||||
|
||||
## Data Sources
|
||||
|
||||
The sensing service (`sensing.service.js`) supports three connection states:
|
||||
The sensing service (`sensing.service.js`) reports these connection states:
|
||||
|
||||
| State | Banner Color | Description |
|
||||
|-------|-------------|-------------|
|
||||
| **LIVE - ESP32** | Green | Connected to the Rust sensing server receiving real CSI data |
|
||||
| **RECONNECTING** | Yellow (pulsing) | WebSocket disconnected, retrying (up to 20 attempts) |
|
||||
| **SIMULATED DATA** | Red | Fallback to client-side simulation after 5+ failed reconnects |
|
||||
| **API TOKEN REQUIRED** | Red | The server has `RUVIEW_API_TOKEN` set and this browser has no valid token. Retries stop; click the banner or open Settings → API Access |
|
||||
|
||||
Simulated frames include a `_simulated: true` marker so code can detect synthetic data.
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ export class DashboardTab {
|
||||
'reconnecting': { text: 'RECONNECTING', status: 'degraded', msg: 'Attempting to connect...' },
|
||||
'unreachable': { text: 'NO DATA', status: 'unhealthy', msg: 'Server unreachable — readings below are stale' },
|
||||
'simulated': { text: 'INVENTED', status: 'unhealthy', msg: 'Browser-generated data, not measured' },
|
||||
'auth-required': { text: 'TOKEN REQUIRED', status: 'unhealthy', msg: 'Paste the server API token in Settings \u2192 API Access' },
|
||||
};
|
||||
const cfg = config[ds] || config['reconnecting'];
|
||||
el.className = `component-status status-${cfg.status}`;
|
||||
|
||||
@@ -1320,6 +1320,7 @@ export class LiveDemoTab {
|
||||
if (ds === 'server-simulated') return 'Active \u2014 Simulated Data';
|
||||
if (ds === 'unreachable') return 'No Data \u2014 Server Unreachable';
|
||||
if (ds === 'simulated') return 'INVENTED DATA \u2014 Not Measured';
|
||||
if (ds === 'auth-required') return 'API Token Required';
|
||||
return 'Connecting...';
|
||||
}
|
||||
|
||||
@@ -1334,6 +1335,7 @@ export class LiveDemoTab {
|
||||
'reconnecting': { text: 'RECONNECTING TO SERVER...', cls: 'demo-source-reconnecting' },
|
||||
'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' },
|
||||
'auth-required': { text: 'API TOKEN REQUIRED \u2014 Settings \u2192 API Access', cls: 'demo-source-offline' },
|
||||
};
|
||||
const cfg = config[ds] || config['reconnecting'];
|
||||
banner.textContent = cfg.text;
|
||||
@@ -1374,6 +1376,7 @@ export class LiveDemoTab {
|
||||
'server-simulated': 'Connected \u2014 Simulated',
|
||||
'reconnecting': 'Reconnecting...',
|
||||
'simulated': 'Offline \u2014 Simulated',
|
||||
'auth-required': 'API token required',
|
||||
};
|
||||
const label = dsLabels[ds] || this.state.connectionState;
|
||||
elements.connectionStatus.textContent = label;
|
||||
|
||||
@@ -216,6 +216,7 @@ export class SensingTab {
|
||||
connected: 'Connected',
|
||||
reconnecting: 'Reconnecting...',
|
||||
simulated: 'Simulated',
|
||||
'auth-required': 'API token required',
|
||||
};
|
||||
dot.className = 'sensing-dot ' + state;
|
||||
text.textContent = stateLabels[state] || state;
|
||||
@@ -230,6 +231,7 @@ export class SensingTab {
|
||||
'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' },
|
||||
'unreachable': { text: 'NO DATA \u2014 SERVER UNREACHABLE', cls: 'sensing-source-simulated' },
|
||||
'simulated': { text: 'INVENTED DATA \u2014 NOT MEASURED', cls: 'sensing-source-simulated' },
|
||||
'auth-required': { text: 'API TOKEN REQUIRED \u2014 SETTINGS \u2192 API ACCESS', cls: 'sensing-source-simulated' },
|
||||
};
|
||||
const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting;
|
||||
banner.textContent = cfg.text;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { withWsTicket } from './ws-ticket.js';
|
||||
import { resolveWsUrl, probeWsAuthRequired } from './ws-ticket.js';
|
||||
import { apiService } from './api.service.js';
|
||||
/**
|
||||
* Sensing WebSocket Service
|
||||
@@ -70,7 +70,7 @@ function clientSimulationAllowed() {
|
||||
}
|
||||
}
|
||||
|
||||
class SensingService {
|
||||
export class SensingService {
|
||||
constructor() {
|
||||
/** @type {WebSocket|null} */
|
||||
this._ws = null;
|
||||
@@ -84,13 +84,19 @@ class SensingService {
|
||||
// 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
|
||||
// Connection state: disconnected | connecting | connected | reconnecting |
|
||||
// simulated | auth-required
|
||||
this._state = 'disconnected';
|
||||
// #2099: why the server refused us, while in `auth-required`:
|
||||
// 'missing' — the server needs a token and the browser has none
|
||||
// 'rejected' — the stored token was refused
|
||||
this._authRequired = null;
|
||||
// Data-source label exposed to the UI:
|
||||
// "live" — real ESP32 hardware connected
|
||||
// "server-simulated" — server is running but using synthetic data (no hardware)
|
||||
// "reconnecting" — WebSocket disconnected, retrying
|
||||
// "simulated" — client-side fallback simulation (server unreachable)
|
||||
// "auth-required" — the server refused us for lack of a valid token
|
||||
this._dataSource = 'reconnecting';
|
||||
// The raw source string from the server (e.g. "esp32", "simulated", "simulate")
|
||||
this._serverSource = null;
|
||||
@@ -152,6 +158,14 @@ class SensingService {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2099: null, or why the server refused the stream —
|
||||
* 'missing' (no token in this browser) or 'rejected' (stored token refused).
|
||||
*/
|
||||
get authRequired() {
|
||||
return this._authRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current data source label.
|
||||
* "live" — frames are arriving from the real ESP32 over WebSocket
|
||||
@@ -194,8 +208,9 @@ class SensingService {
|
||||
this._setState('connecting');
|
||||
|
||||
let url = SENSING_WS_URL;
|
||||
let authRequired = null;
|
||||
try {
|
||||
url = await withWsTicket(SENSING_WS_URL);
|
||||
({ url, authRequired } = await resolveWsUrl(SENSING_WS_URL));
|
||||
} catch {
|
||||
// Ticket minting is best-effort: against a server with auth off, or one
|
||||
// predating ADR-272, connecting without a ticket is correct.
|
||||
@@ -208,6 +223,15 @@ class SensingService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (authRequired) {
|
||||
// #2099: the stored token was refused. Retrying with it cannot succeed.
|
||||
this._connectInFlight = false;
|
||||
this._enterAuthRequired(authRequired);
|
||||
return;
|
||||
}
|
||||
const ticketed = url !== SENSING_WS_URL;
|
||||
let opened = false;
|
||||
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
@@ -230,6 +254,8 @@ class SensingService {
|
||||
return;
|
||||
}
|
||||
console.info('[Sensing] Connected to', SENSING_WS_URL);
|
||||
opened = true;
|
||||
this._authRequired = null;
|
||||
this._reconnectAttempt = 0;
|
||||
this._stopSimulation();
|
||||
this._setState('connected');
|
||||
@@ -261,7 +287,11 @@ class SensingService {
|
||||
}
|
||||
console.info('[Sensing] Connection closed (code=%d)', evt.code);
|
||||
this._ws = null;
|
||||
if (evt.code !== 1000) {
|
||||
if (evt.code !== 1000 && !opened && !ticketed) {
|
||||
// #2099: a refused upgrade looks like any other failure from here, so
|
||||
// ask the server whether it wants a token before retrying forever.
|
||||
void this._handleRefusedConnect(epoch);
|
||||
} else if (evt.code !== 1000) {
|
||||
this._scheduleReconnect();
|
||||
} else {
|
||||
this._setState('disconnected');
|
||||
@@ -270,6 +300,39 @@ class SensingService {
|
||||
};
|
||||
}
|
||||
|
||||
async _handleRefusedConnect(epoch) {
|
||||
const reason = await probeWsAuthRequired();
|
||||
// stop(), reconnect() or another attempt took over during the probe.
|
||||
if (epoch !== this._connectEpoch || this._ws) return;
|
||||
if (reason) {
|
||||
this._enterAuthRequired(reason);
|
||||
} else {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #2099: stop the retry loop and say why. Nothing is retried until the user
|
||||
* supplies a token (QuickSettings reloads the page on save) or asks for a
|
||||
* reconnect.
|
||||
*/
|
||||
_enterAuthRequired(reason) {
|
||||
console.warn('[Sensing] Server requires an API token (%s); not retrying', reason);
|
||||
this._clearTimers();
|
||||
this._reconnectAttempt = 0;
|
||||
this._authRequired = reason;
|
||||
this._setState('auth-required');
|
||||
this._setDataSource('auth-required');
|
||||
}
|
||||
|
||||
/** Retry now, e.g. from the header's reconnect button. */
|
||||
reconnect() {
|
||||
this._clearTimers();
|
||||
this._authRequired = null;
|
||||
this._reconnectAttempt = 0;
|
||||
void this._connect();
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
// A retry is already pending; stacking timers would multiply the attempt
|
||||
// count for a single outage.
|
||||
@@ -529,7 +592,7 @@ class SensingService {
|
||||
/**
|
||||
* Update the dataSource label and notify state listeners so the UI can
|
||||
* react without needing a separate subscription.
|
||||
* @param {'live'|'server-simulated'|'reconnecting'|'simulated'} source
|
||||
* @param {'live'|'server-simulated'|'reconnecting'|'unreachable'|'simulated'|'auth-required'} source
|
||||
*/
|
||||
_setDataSource(source) {
|
||||
if (source === this._dataSource) return;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Executed regression test for #2099: with RUVIEW_API_TOKEN set on the server
|
||||
// and no token in the browser, the sensing service retried forever and the
|
||||
// header said "Connecting..." with no hint that a token was needed.
|
||||
//
|
||||
// Run: node --test ui/services/sensing.service.test.mjs
|
||||
//
|
||||
// This EXECUTES SensingService in Node with a stub WebSocket that refuses the
|
||||
// upgrade the way a browser reports a 401 (close code 1006, no open), and a
|
||||
// stubbed fetch standing in for the server. It does not drive a browser or the
|
||||
// page wiring (banner, header widget, QuickSettings).
|
||||
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const STORAGE_KEY = 'ruview-api-token';
|
||||
|
||||
let stored = {};
|
||||
let fetchCalls = [];
|
||||
// Server stand-in: auth on, no session. Status probe and ticket mint both 401.
|
||||
let ticketStatus = 401;
|
||||
let sockets = [];
|
||||
let refuseUpgrade = true;
|
||||
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (k in stored ? stored[k] : null),
|
||||
setItem: (k, v) => { stored[k] = String(v); },
|
||||
removeItem: (k) => { delete stored[k]; },
|
||||
};
|
||||
globalThis.fetch = async (path, init = {}) => {
|
||||
fetchCalls.push([path, init]);
|
||||
if (path === '/api/v1/ws-ticket') {
|
||||
const ok = ticketStatus === 200;
|
||||
return { ok, status: ticketStatus, json: async () => (ok ? { ticket: 'T' } : {}) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({ source: 'simulated' }) };
|
||||
};
|
||||
|
||||
class FakeWebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
sockets.push(this);
|
||||
setTimeout(() => {
|
||||
if (refuseUpgrade) {
|
||||
this.readyState = 3;
|
||||
this.onerror?.({});
|
||||
this.onclose?.({ code: 1006 });
|
||||
} else {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
close() { this.readyState = 3; }
|
||||
}
|
||||
globalThis.WebSocket = FakeWebSocket;
|
||||
|
||||
const { SensingService } = await import('./sensing.service.js');
|
||||
|
||||
const settle = (ms = 30) => new Promise((r) => setTimeout(r, ms));
|
||||
let svc;
|
||||
|
||||
beforeEach(() => {
|
||||
stored = {};
|
||||
fetchCalls = [];
|
||||
ticketStatus = 401;
|
||||
sockets = [];
|
||||
refuseUpgrade = true;
|
||||
svc = new SensingService();
|
||||
});
|
||||
|
||||
afterEach(() => svc.stop());
|
||||
|
||||
test('no token + server requires one: stops retrying and says why', async () => {
|
||||
const states = [];
|
||||
svc.onStateChange((s) => states.push(s));
|
||||
svc.start();
|
||||
await settle();
|
||||
|
||||
assert.equal(svc.state, 'auth-required');
|
||||
assert.equal(svc.dataSource, 'auth-required');
|
||||
assert.equal(svc.authRequired, 'missing');
|
||||
assert.equal(svc._reconnectTimer, null, 'no retry may be pending');
|
||||
|
||||
// Before the fix this kept opening sockets on a 1-16 s back-off forever.
|
||||
await settle(1200);
|
||||
assert.equal(sockets.length, 1, 'exactly one refused attempt, then no retries');
|
||||
assert.ok(!states.includes('reconnecting'), `went through reconnecting: ${states}`);
|
||||
});
|
||||
|
||||
test('a stored token the server rejects is reported without opening a socket', async () => {
|
||||
stored[STORAGE_KEY] = 'stale';
|
||||
svc.start();
|
||||
await settle();
|
||||
assert.equal(svc.state, 'auth-required');
|
||||
assert.equal(svc.authRequired, 'rejected');
|
||||
assert.equal(sockets.length, 0);
|
||||
});
|
||||
|
||||
test('a plain outage still reconnects (auth off, server down)', async () => {
|
||||
// Server unreachable: the probe's fetch fails too.
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); };
|
||||
try {
|
||||
svc.start();
|
||||
await settle();
|
||||
assert.equal(svc.state, 'reconnecting');
|
||||
assert.equal(svc.authRequired, null);
|
||||
assert.ok(svc._reconnectTimer, 'a retry must be scheduled');
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('auth off and server up: no probe on the happy path', async () => {
|
||||
ticketStatus = 200;
|
||||
refuseUpgrade = false;
|
||||
svc.start();
|
||||
await settle();
|
||||
assert.equal(svc.state, 'connected');
|
||||
assert.ok(!fetchCalls.some(([p]) => p === '/api/v1/ws-ticket'),
|
||||
'no ticket request when no token is stored and the socket opens');
|
||||
});
|
||||
|
||||
test('reconnect() leaves auth-required and tries again', async () => {
|
||||
svc.start();
|
||||
await settle();
|
||||
assert.equal(svc.state, 'auth-required');
|
||||
|
||||
refuseUpgrade = false;
|
||||
svc.reconnect();
|
||||
await settle();
|
||||
assert.equal(svc.state, 'connected');
|
||||
assert.equal(svc.authRequired, null);
|
||||
});
|
||||
+59
-10
@@ -27,12 +27,16 @@ function storedToken() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a ticket. Returns null when no token is configured (auth is off, so no
|
||||
* ticket is needed) or when the server does not offer the endpoint.
|
||||
* Mint a ticket. Resolves to `{ ticket, authRequired }`:
|
||||
*
|
||||
* - `ticket` is null when no token is configured (auth is off, so no ticket is
|
||||
* needed) or when the server does not offer the endpoint.
|
||||
* - `authRequired` is `'rejected'` when the stored token was refused (401/403),
|
||||
* so the caller can stop retrying with it. It is null otherwise.
|
||||
*/
|
||||
async function mintTicket() {
|
||||
const token = storedToken();
|
||||
if (!token) return null;
|
||||
if (!token) return { ticket: null, authRequired: null };
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/v1/ws-ticket', {
|
||||
@@ -42,18 +46,66 @@ async function mintTicket() {
|
||||
// 404 means a server predating ADR-272: it still exempts WebSockets, so
|
||||
// connecting without a ticket is correct there. Treated as "no ticket
|
||||
// needed" rather than as an error, so the UI works against both.
|
||||
if (resp.status === 404) return null;
|
||||
if (resp.status === 404) return { ticket: null, authRequired: null };
|
||||
// #2099: a wrong or revoked token. Retrying with it can never succeed.
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
console.warn('[ws-ticket] stored API token was rejected:', resp.status);
|
||||
return { ticket: null, authRequired: 'rejected' };
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[ws-ticket] mint failed:', resp.status);
|
||||
return null;
|
||||
return { ticket: null, authRequired: null };
|
||||
}
|
||||
const body = await resp.json();
|
||||
return body.ticket || null;
|
||||
return { ticket: body.ticket || null, authRequired: null };
|
||||
} catch (err) {
|
||||
// Offline, or the server is down. The socket attempt will fail on its own
|
||||
// and the caller's reconnect logic handles it; failing loudly here would
|
||||
// just duplicate that.
|
||||
console.warn('[ws-ticket] mint error:', err.message);
|
||||
return { ticket: null, authRequired: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `withWsTicket`, but also reports when the stored token was rejected.
|
||||
* Resolves to `{ url, authRequired }` with `authRequired` null or `'rejected'`.
|
||||
*
|
||||
* @param {string} url ws:// or wss:// URL
|
||||
* @returns {Promise<{url: string, authRequired: null|'rejected'}>}
|
||||
*/
|
||||
export async function resolveWsUrl(url) {
|
||||
const { ticket, authRequired } = await mintTicket();
|
||||
if (!ticket) return { url, authRequired };
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return { url: `${url}${sep}ticket=${encodeURIComponent(ticket)}`, authRequired: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* #2099: ask the server whether a socket refusal was an auth refusal.
|
||||
*
|
||||
* A browser cannot see the status of a refused WebSocket upgrade; it only gets
|
||||
* a close event. So after an unticketed socket closes without ever opening,
|
||||
* repeat the ticket request with no bearer. The server answers 401 exactly
|
||||
* when it requires a credential the browser does not have (a signed-in session
|
||||
* cookie still counts, since `fetch` sends it same-origin). Returns
|
||||
* `'missing'` in that case and null for anything else, including a network
|
||||
* failure, which is an outage rather than an auth problem.
|
||||
*
|
||||
* Only called on the failure path, so a server with auth off sees no extra
|
||||
* request while it is reachable.
|
||||
*
|
||||
* @returns {Promise<null|'missing'>}
|
||||
*/
|
||||
export async function probeWsAuthRequired() {
|
||||
if (storedToken()) return null; // mintTicket already judged the stored token
|
||||
try {
|
||||
const resp = await fetch('/api/v1/ws-ticket', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
return resp.status === 401 || resp.status === 403 ? 'missing' : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -70,8 +122,5 @@ async function mintTicket() {
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function withWsTicket(url) {
|
||||
const ticket = await mintTicket();
|
||||
if (!ticket) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}ticket=${encodeURIComponent(ticket)}`;
|
||||
return (await resolveWsUrl(url)).url;
|
||||
}
|
||||
@@ -111,3 +111,57 @@ test('a fresh ticket is minted per call and never reused', async () => {
|
||||
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T2');
|
||||
assert.equal(fetchCalls.length, 2, 'each connect attempt must mint its own');
|
||||
});
|
||||
|
||||
// ---- #2099: auth-required detection ----------------------------------------
|
||||
|
||||
const { resolveWsUrl, probeWsAuthRequired } = await import('./ws-ticket.js');
|
||||
|
||||
test('a stored token that the server refuses is reported as rejected', async () => {
|
||||
// Retrying with a token the server has refused can never succeed, so the
|
||||
// caller needs to know rather than loop.
|
||||
stored[STORAGE_KEY] = 'wrong';
|
||||
for (const status of [401, 403]) {
|
||||
fetchImpl = async () => ({ ok: false, status, json: async () => ({}) });
|
||||
const r = await resolveWsUrl('ws://host/ws/sensing');
|
||||
assert.deepEqual(r, { url: 'ws://host/ws/sensing', authRequired: 'rejected' });
|
||||
}
|
||||
});
|
||||
|
||||
test('withWsTicket keeps its string contract when the token is refused', async () => {
|
||||
stored[STORAGE_KEY] = 'wrong';
|
||||
fetchImpl = async () => ({ ok: false, status: 401, json: async () => ({}) });
|
||||
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
|
||||
});
|
||||
|
||||
test('a 503 or a 404 is not an auth refusal', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
for (const status of [404, 503]) {
|
||||
fetchImpl = async () => ({ ok: false, status, json: async () => ({}) });
|
||||
assert.equal((await resolveWsUrl('ws://h/ws/sensing')).authRequired, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('the probe reports a missing token when the server answers 401 with no bearer', async () => {
|
||||
fetchImpl = async () => ({ ok: false, status: 401, json: async () => ({}) });
|
||||
assert.equal(await probeWsAuthRequired(), 'missing');
|
||||
const [path, init] = fetchCalls[0];
|
||||
assert.equal(path, '/api/v1/ws-ticket');
|
||||
assert.equal(init.method, 'POST');
|
||||
assert.equal(init.headers, undefined, 'the probe must not send a bearer');
|
||||
assert.equal(init.credentials, 'same-origin', 'a signed-in session cookie still counts');
|
||||
});
|
||||
|
||||
test('the probe treats success, other errors and network failure as not-auth', async () => {
|
||||
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
|
||||
assert.equal(await probeWsAuthRequired(), null, 'auth off or a valid session');
|
||||
fetchImpl = async () => ({ ok: false, status: 500, json: async () => ({}) });
|
||||
assert.equal(await probeWsAuthRequired(), null);
|
||||
fetchImpl = async () => { throw new Error('offline'); };
|
||||
assert.equal(await probeWsAuthRequired(), null, 'an outage is not an auth problem');
|
||||
});
|
||||
|
||||
test('the probe does not second-guess a stored token', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
assert.equal(await probeWsAuthRequired(), null);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
@@ -3,6 +3,11 @@
|
||||
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
|
||||
/** Ask QuickSettings to open on its API Access section (#2099). */
|
||||
export function openApiAccess(reason) {
|
||||
document.dispatchEvent(new CustomEvent('ruview-open-api-access', { detail: { reason } }));
|
||||
}
|
||||
|
||||
export class ConnectionStatus {
|
||||
constructor() {
|
||||
this.widget = null;
|
||||
@@ -27,6 +32,15 @@ export class ConnectionStatus {
|
||||
</button>
|
||||
`;
|
||||
|
||||
// #2099: while the server wants a token, the widget is the way to it.
|
||||
this.widget.addEventListener('click', (e) => {
|
||||
if (sensingService.state !== 'auth-required') return;
|
||||
if (e.target.closest('.conn-status-reconnect')) return;
|
||||
// QuickSettings closes on any outside click; this click must not count.
|
||||
e.stopPropagation();
|
||||
openApiAccess(sensingService.authRequired);
|
||||
});
|
||||
|
||||
this.widget.querySelector('.conn-status-reconnect').addEventListener('click', () => {
|
||||
this.setStatus('reconnecting', 'Reconnecting...');
|
||||
sensingService.reconnect?.();
|
||||
@@ -58,6 +72,17 @@ export class ConnectionStatus {
|
||||
this.setStatus('connected', label);
|
||||
} else if (state === 'connecting' || state === 'reconnecting') {
|
||||
this.setStatus('reconnecting', 'Connecting...');
|
||||
} else if (state === 'auth-required') {
|
||||
this.setStatus('error', 'Token required');
|
||||
this.widget.title = 'This server requires an API token. Click to open Settings \u2192 API Access.';
|
||||
// Open the panel once per page load; after that the widget stays
|
||||
// clickable but does not keep popping the panel up.
|
||||
if (!this._openedApiAccess) {
|
||||
this._openedApiAccess = true;
|
||||
// Deferred so QuickSettings, initialised after this widget in the same
|
||||
// synchronous pass, is listening by the time the event fires.
|
||||
setTimeout(() => openApiAccess(sensingService.authRequired), 0);
|
||||
}
|
||||
} else if (state === 'error') {
|
||||
this.setStatus('error', 'Error');
|
||||
} else {
|
||||
@@ -67,6 +92,8 @@ export class ConnectionStatus {
|
||||
|
||||
setStatus(status, label) {
|
||||
if (!this.widget) return;
|
||||
if (sensingService.state !== 'auth-required') this.widget.removeAttribute('title');
|
||||
this.widget.style.cursor = sensingService.state === 'auth-required' ? 'pointer' : '';
|
||||
this.widget.className = `conn-status conn-status-${status}`;
|
||||
this.widget.querySelector('.conn-status-label').textContent = label;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// meaningful.
|
||||
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
import { openApiAccess } from './connection-status.js';
|
||||
|
||||
const STYLE_ID = 'ruview-data-source-banner-style';
|
||||
const BAR_ID = 'ruview-data-source-banner';
|
||||
@@ -37,6 +38,13 @@ const CONFIG = {
|
||||
bg: '#6b0f6b',
|
||||
fg: '#ffc9ff',
|
||||
},
|
||||
// #2099: the server refused the stream. Clicking the bar opens API Access.
|
||||
'auth-required': {
|
||||
text: 'API TOKEN REQUIRED — this server needs its API token. Click here, or Settings → API Access',
|
||||
textRejected: 'API TOKEN REJECTED — the saved token was refused. Click here, or Settings → API Access',
|
||||
bg: '#5c1010',
|
||||
fg: '#ffc9c9',
|
||||
},
|
||||
};
|
||||
|
||||
const CSS = `
|
||||
@@ -90,6 +98,12 @@ export class DataSourceBanner {
|
||||
// message describes context, not an error to act on right now.
|
||||
this._bar.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(this._bar);
|
||||
this._bar.addEventListener('click', (e) => {
|
||||
if (sensingService.dataSource !== 'auth-required') return;
|
||||
// QuickSettings closes on any outside click; this click must not count.
|
||||
e.stopPropagation();
|
||||
openApiAccess(sensingService.authRequired);
|
||||
});
|
||||
}
|
||||
|
||||
// dataSource changes are published on the state-listener channel.
|
||||
@@ -107,7 +121,9 @@ export class DataSourceBanner {
|
||||
return;
|
||||
}
|
||||
|
||||
this._bar.textContent = cfg.text;
|
||||
this._bar.textContent =
|
||||
cfg.textRejected && sensingService.authRequired === 'rejected' ? cfg.textRejected : cfg.text;
|
||||
this._bar.style.cursor = sensingService.dataSource === 'auth-required' ? 'pointer' : '';
|
||||
this._bar.style.background = cfg.bg;
|
||||
this._bar.style.color = cfg.fg;
|
||||
this._bar.classList.add('is-visible');
|
||||
|
||||
@@ -196,6 +196,10 @@ export class QuickSettings {
|
||||
|
||||
document.body.appendChild(this.panel);
|
||||
|
||||
// #2099: the dashboard asks for this when the server refused the stream
|
||||
// for lack of a valid token.
|
||||
document.addEventListener('ruview-open-api-access', (e) => this.openApiAccess(e.detail?.reason));
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
|
||||
@@ -245,6 +249,22 @@ export class QuickSettings {
|
||||
void refreshSignInPanel(this.panel);
|
||||
}
|
||||
|
||||
/** Open on the API Access section and say why (#2099). */
|
||||
openApiAccess(reason) {
|
||||
this.open();
|
||||
const status = this.panel.querySelector('#qs-api-token-status');
|
||||
if (status) {
|
||||
status.textContent = reason === 'rejected'
|
||||
? 'The server rejected the saved token. Paste the current RUVIEW_API_TOKEN and Save & Apply.'
|
||||
: 'This server requires an API token. Paste RUVIEW_API_TOKEN and Save & Apply.';
|
||||
}
|
||||
const input = this.panel.querySelector('#qs-api-token');
|
||||
if (input) {
|
||||
input.scrollIntoView?.({ block: 'center' });
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.panel.classList.remove('open');
|
||||
|
||||
Reference in new issue
Block a user