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:
parent
339a1d8c43
commit
95a8e239fd
4 changed files with 94 additions and 10 deletions
|
|
@ -11,11 +11,18 @@ import { dashboardReducer, initialDashboardState } from './state/dashboardReduce
|
|||
|
||||
const BOOTSTRAP_PAGE_SIZE = 20;
|
||||
|
||||
function LoadingPanel() {
|
||||
function LoadingPanel({ error, onRetry }) {
|
||||
return (
|
||||
<div className="panel">
|
||||
<h2>Loading dashboard</h2>
|
||||
<p className="panel-subtitle">Fetching session, durable history, and live service state.</p>
|
||||
<h2>{error ? 'Dashboard unavailable' : 'Loading dashboard'}</h2>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
<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} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
test/operator-dashboard-api-client.test.mjs
Normal file
36
test/operator-dashboard-api-client.test.mjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
|
|
@ -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, /<LoadingPanel[\s\S]*error=\{state\.error\}/);
|
||||
assert.match(appSource, /Retry/);
|
||||
assert.match(appSource, /bootDashboard\(\)\.catch/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue