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, /