diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index 75efd26..0d09d5c 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -10,6 +10,7 @@ import { parseEventMessage } from '../core/event-envelope.mjs'; import { applyDashboardLiveEvent, buildDashboardBootstrap, + buildDashboardControlErrorResponse, buildLiveStatusBar, createDashboardLiveState, listDashboardServices, @@ -212,7 +213,7 @@ const server = http.createServer(async (req, res) => { if (!auth) return; if (url.pathname.startsWith('/api/')) { - return handleApiRequest({ req, res, url, auth }); + return await handleApiRequest({ req, res, url, auth }); } if (req.method === 'GET' && staticAssets.has(url.pathname)) { @@ -318,16 +319,37 @@ async function handleApiRequest({ req, res, url, auth }) { }); } - const result = await invokeControl(control, body || {}); - const serviceSnapshot = await loadServiceSnapshot( - listDashboardServices(config).find((definition) => definition.service === control.service), - ); - return sendJson(res, 200, { - ok: true, - control, - result, - service_snapshot: serviceSnapshot, - }); + const serviceDefinition = listDashboardServices(config) + .find((definition) => definition.service === control.service); + try { + const result = await invokeControl(control, body || {}); + const serviceSnapshot = await loadServiceSnapshot(serviceDefinition); + return sendJson(res, 200, { + ok: true, + control, + result, + service_snapshot: serviceSnapshot, + }); + } catch (error) { + logger.warn('dashboard_control_failed', { + details: { + control, + error: serializeError(error), + }, + }); + const serviceSnapshot = await loadServiceSnapshot(serviceDefinition).catch((snapshotError) => ({ + ...serviceDefinition, + reachable: false, + state: null, + health: null, + error: serializeError(snapshotError), + })); + const failure = buildDashboardControlErrorResponse(error, { control }); + return sendJson(res, failure.statusCode, { + ...failure.payload, + service_snapshot: serviceSnapshot, + }); + } } return sendJson(res, 404, { error: 'not_found' }); diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index 8b10ea4..54785fd 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -245,6 +245,21 @@ export function resolveDashboardControl({ service, action }) { )) || null; } +export function buildDashboardControlErrorResponse(error, { control = null } = {}) { + const name = String(error?.name || 'Error'); + const message = String(error?.message || 'Control request failed.'); + const timedOut = name === 'TimeoutError' || /timeout|aborted/i.test(message); + return { + statusCode: timedOut ? 504 : 502, + payload: { + ok: false, + error: timedOut ? 'control_timeout' : 'control_failed', + reason: message, + control, + }, + }; +} + export function listDashboardServices(config) { return SERVICE_DEFINITIONS.map(([service, label, configKey]) => ({ service, diff --git a/test/operator-dashboard-app-static.test.mjs b/test/operator-dashboard-app-static.test.mjs new file mode 100644 index 0000000..5b5014b --- /dev/null +++ b/test/operator-dashboard-app-static.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('../src/apps/operator-dashboard.mjs', import.meta.url), 'utf8'); + +test('operator dashboard awaits API handler promises so request errors reach the top-level catch', () => { + assert.equal(source.includes('return await handleApiRequest({ req, res, url, auth });'), true); + assert.equal(source.includes('return handleApiRequest({ req, res, url, auth });'), false); +}); + +test('operator dashboard control proxy catches upstream failures before sending JSON response', () => { + assert.match(source, /dashboard_control_failed/); + assert.match(source, /buildDashboardControlErrorResponse/); + assert.match(source, /failure.statusCode/); +}); diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index 66c5726..9f986e9 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { applyDashboardLiveEvent, buildDashboardBootstrap, + buildDashboardControlErrorResponse, buildLiveStatusBar, buildProfitabilitySummary, createDashboardLiveState, @@ -104,6 +105,21 @@ test('profitability summary flags cash-flow-adjusted benchmarks after later fund assert.match(summary.caveats[0], /external cash flows/); }); +test('dashboard control errors become structured responses instead of uncaught failures', () => { + const control = resolveDashboardControl({ + service: 'trade-executor', + action: 'intent-request-preflight', + }); + const timeout = new DOMException('The operation was aborted due to timeout', 'TimeoutError'); + const response = buildDashboardControlErrorResponse(timeout, { control }); + + assert.equal(response.statusCode, 504); + assert.equal(response.payload.ok, false); + assert.equal(response.payload.error, 'control_timeout'); + assert.equal(response.payload.control.action, 'intent-request-preflight'); + assert.match(response.payload.reason, /timeout/i); +}); + test('control routing only resolves the allowlisted safe dashboard actions', () => { const refresh = resolveDashboardControl({ service: 'liquidity-manager',