From 95a8e239fd7266291b99f8795053c569333fd980 Mon Sep 17 00:00:00 2001 From: philipp Date: Tue, 12 May 2026 22:18:37 +0200 Subject: [PATCH] Fail dashboard bootstrap visibly Proof: npm test passed 164/164; npm run operator-dashboard:build passed; focused dashboard API client and UI static tests cover timeout, empty HTTP 500, and retry/error loading state. Assumptions: The current stuck dashboard is caused by a stale or unavailable local kubectl port-forward; the app should fail visibly and allow retry instead of waiting forever. Still fake: no public dashboard ingress is part of this turn; local dashboard access still depends on Kubernetes API/port-forward availability. --- src/operator-dashboard/static/App.jsx | 29 ++++++++++++++--- src/operator-dashboard/static/lib/api.js | 31 ++++++++++++++---- test/operator-dashboard-api-client.test.mjs | 36 +++++++++++++++++++++ test/operator-dashboard-ui-static.test.mjs | 8 +++++ 4 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 test/operator-dashboard-api-client.test.mjs diff --git a/src/operator-dashboard/static/App.jsx b/src/operator-dashboard/static/App.jsx index 7e04553..a7c2f58 100644 --- a/src/operator-dashboard/static/App.jsx +++ b/src/operator-dashboard/static/App.jsx @@ -11,11 +11,18 @@ import { dashboardReducer, initialDashboardState } from './state/dashboardReduce const BOOTSTRAP_PAGE_SIZE = 20; -function LoadingPanel() { +function LoadingPanel({ error, onRetry }) { return (
-

Loading dashboard

-

Fetching session, durable history, and live service state.

+

{error ? 'Dashboard unavailable' : 'Loading dashboard'}

+

+ {error || 'Fetching session, durable history, and live service state.'} +

+ {error ? ( + + ) : null}
); } @@ -32,6 +39,13 @@ export default function App() { return dashboard; } + async function bootDashboard() { + dispatch({ type: 'error.changed', error: null }); + const session = await fetchJson('/api/session'); + dispatch({ type: 'session.loaded', session }); + await loadBootstrap(1); + } + async function submitControl(service, action, body = {}, { reload = true } = {}) { dispatch({ type: 'notice.changed', notice: `${action} in progress` }); dispatch({ type: 'error.changed', error: null }); @@ -135,7 +149,14 @@ export default function App() { /> {!state.dashboard ? ( - + { + bootDashboard().catch((error) => { + dispatch({ type: 'error.changed', error: error.message }); + }); + }} + /> ) : ( <> diff --git a/src/operator-dashboard/static/lib/api.js b/src/operator-dashboard/static/lib/api.js index 2b59092..8edca32 100644 --- a/src/operator-dashboard/static/lib/api.js +++ b/src/operator-dashboard/static/lib/api.js @@ -1,11 +1,30 @@ export async function fetchJson(url, options = {}) { - const response = await fetch(url, options); - const text = await response.text(); - const data = text ? JSON.parse(text) : null; + const { timeoutMs = 15_000, ...fetchOptions } = options; + let timeout = null; + let controller = null; - if (!response.ok) { - throw new Error(data?.error || `HTTP ${response.status}`); + if (!fetchOptions.signal && timeoutMs > 0) { + controller = new AbortController(); + fetchOptions.signal = controller.signal; + timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs); } - return data; + try { + const response = await fetch(url, fetchOptions); + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + + if (!response.ok) { + throw new Error(data?.error || `HTTP ${response.status}`); + } + + return data; + } catch (error) { + if (controller?.signal.aborted) { + throw new Error(`Request timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + if (timeout) globalThis.clearTimeout(timeout); + } } diff --git a/test/operator-dashboard-api-client.test.mjs b/test/operator-dashboard-api-client.test.mjs new file mode 100644 index 0000000..fb3380f --- /dev/null +++ b/test/operator-dashboard-api-client.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { fetchJson } from '../src/operator-dashboard/static/lib/api.js'; + +test('dashboard fetch helper reports empty upstream 500 responses', async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + globalThis.fetch = async () => new Response('', { status: 500 }); + + await assert.rejects( + fetchJson('/api/session', { timeoutMs: 0 }), + /HTTP 500/, + ); +}); + +test('dashboard fetch helper times out stale port-forward requests', async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + globalThis.fetch = async (_url, options = {}) => new Promise((_resolve, reject) => { + options.signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + }); + + await assert.rejects( + fetchJson('/api/bootstrap', { timeoutMs: 1 }), + /Request timed out after 1ms/, + ); +}); diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index d329ae4..3e67aad 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -8,6 +8,7 @@ const stylesSource = readFileSync(new URL('../src/operator-dashboard/static/styl const serviceCardSource = readFileSync(new URL('../src/operator-dashboard/static/components/ServiceCard.jsx', import.meta.url), 'utf8'); const statusBarSource = readFileSync(new URL('../src/operator-dashboard/static/components/StatusBar.jsx', import.meta.url), 'utf8'); const systemSource = readFileSync(new URL('../src/operator-dashboard/static/pages/SystemPage.jsx', import.meta.url), 'utf8'); +const appSource = readFileSync(new URL('../src/operator-dashboard/static/App.jsx', import.meta.url), 'utf8'); test('strategy page owns consolidated quote lifecycle and successful trade tables', () => { assert.match(strategySource, /Quote lifecycle/); @@ -64,3 +65,10 @@ test('system page exposes deduped environmental conditions history', () => { assert.match(systemSource, /NEAR Intents upstream status changes/); assert.match(systemSource, /status_fingerprint/); }); + +test('dashboard loading state exposes API failures and retry action', () => { + assert.match(appSource, /Dashboard unavailable/); + assert.match(appSource, /