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;
|
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} />
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 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/);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue