Merge PR #2128 (aepod) into release integration

# Conflicts:
#	.github/workflows/ci.yml
This commit is contained in:
ruv committed 2026-10-05 11:48:40 -04:00
commit fd323e483e
12 files changed
+392 -19

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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}`;
+3
View File
@@ -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;
+2
View File
@@ -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;
+69 -6
View File
@@ -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;
+137
View File
@@ -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
View File
@@ -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;
}
+54
View File
@@ -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);
});
+27
View File
@@ -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;
+17 -1
View File
@@ -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');
+20
View File
@@ -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');