Fail dashboard bootstrap visibly
Some checks failed
deploy / deploy (push) Failing after 39s

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.
This commit is contained in:
philipp 2026-05-12 22:18:37 +02:00
parent 339a1d8c43
commit 95a8e239fd
4 changed files with 94 additions and 10 deletions

View file

@ -11,11 +11,18 @@ import { dashboardReducer, initialDashboardState } from './state/dashboardReduce
const BOOTSTRAP_PAGE_SIZE = 20; const BOOTSTRAP_PAGE_SIZE = 20;
function LoadingPanel() { function LoadingPanel({ error, onRetry }) {
return ( return (
<div className="panel"> <div className="panel">
<h2>Loading dashboard</h2> <h2>{error ? 'Dashboard unavailable' : 'Loading dashboard'}</h2>
<p className="panel-subtitle">Fetching session, durable history, and live service state.</p> <p className="panel-subtitle">
{error || 'Fetching session, durable history, and live service state.'}
</p>
{error ? (
<button className="button secondary" onClick={onRetry} type="button">
Retry
</button>
) : null}
</div> </div>
); );
} }
@ -32,6 +39,13 @@ export default function App() {
return dashboard; 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 } = {}) { async function submitControl(service, action, body = {}, { reload = true } = {}) {
dispatch({ type: 'notice.changed', notice: `${action} in progress` }); dispatch({ type: 'notice.changed', notice: `${action} in progress` });
dispatch({ type: 'error.changed', error: null }); dispatch({ type: 'error.changed', error: null });
@ -135,7 +149,14 @@ export default function App() {
/> />
{!state.dashboard ? ( {!state.dashboard ? (
<LoadingPanel /> <LoadingPanel
error={state.error}
onRetry={() => {
bootDashboard().catch((error) => {
dispatch({ type: 'error.changed', error: error.message });
});
}}
/>
) : ( ) : (
<> <>
<StatusBar status={state.dashboard.status_bar} websocketState={state.websocketState} /> <StatusBar status={state.dashboard.status_bar} websocketState={state.websocketState} />

View file

@ -1,11 +1,30 @@
export async function fetchJson(url, options = {}) { export async function fetchJson(url, options = {}) {
const response = await fetch(url, options); const { timeoutMs = 15_000, ...fetchOptions } = options;
const text = await response.text(); let timeout = null;
const data = text ? JSON.parse(text) : null; let controller = null;
if (!response.ok) { if (!fetchOptions.signal && timeoutMs > 0) {
throw new Error(data?.error || `HTTP ${response.status}`); 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);
}
} }

View file

@ -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/,
);
});

View file

@ -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 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 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 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', () => { test('strategy page owns consolidated quote lifecycle and successful trade tables', () => {
assert.match(strategySource, /Quote lifecycle/); 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, /NEAR Intents upstream status changes/);
assert.match(systemSource, /status_fingerprint/); assert.match(systemSource, /status_fingerprint/);
}); });
test('dashboard loading state exposes API failures and retry action', () => {
assert.match(appSource, /Dashboard unavailable/);
assert.match(appSource, /<LoadingPanel[\s\S]*error=\{state\.error\}/);
assert.match(appSource, /Retry/);
assert.match(appSource, /bootDashboard\(\)\.catch/);
});