From 3c1ad1dde4cf60a9d60a8a0bf5448ba6f54d9d26 Mon Sep 17 00:00:00 2001 From: philipp Date: Wed, 8 Apr 2026 21:27:03 +0200 Subject: [PATCH] Clarify executor controls and alert history Proof: Dashboard system controls and alert history stay operator-legible under runtime health flapping without implying nonexistent arm behavior. Assumptions: Manual executor arming remains intentionally absent from the dashboard for this turn, so resume should mean intake resume only. Still fake: Ops-sentinel still emits raw runtime transition churn underneath; this change collapses it in the dashboard instead of changing runtime alert hysteresis. --- src/core/operator-dashboard.mjs | 42 ++++++-- .../static/components/AlertsGrid.jsx | 20 +++- test/operator-dashboard.test.mjs | 97 +++++++++++++++++++ 3 files changed, 151 insertions(+), 8 deletions(-) diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index ad3721d..63f27bb 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -130,8 +130,8 @@ const CONTROL_DEFINITIONS = [ action: 'pause', method: 'POST', path: '/pause', - label: 'Pause Executor', - description: 'Pause trade-executor command consumption without moving funds.', + label: 'Pause Executor Intake', + description: 'Pause trade-executor command consumption without changing armed state.', page: 'system', risk_class: 'safe', }, @@ -140,8 +140,8 @@ const CONTROL_DEFINITIONS = [ action: 'resume', method: 'POST', path: '/resume', - label: 'Resume Executor', - description: 'Resume trade-executor command consumption.', + label: 'Resume Executor Intake', + description: 'Resume trade-executor command consumption without changing armed state.', page: 'system', risk_class: 'safe', }, @@ -329,11 +329,11 @@ export function buildDashboardBootstrap({ const activeAlerts = normalizeAlertList( servicesByName['ops-sentinel']?.state?.active_alerts || [], ); - const recentAlerts = normalizeAlertList( + const recentAlerts = summarizeRecentAlertTransitions(normalizeAlertList( servicesByName['ops-sentinel']?.state?.recent_transitions || recentAlertTransitions?.map((entry) => entry.payload) || [], - ); + )); const profitability = buildProfitabilitySummary({ metric: portfolioMetric, successfulTradeSummary, @@ -1072,9 +1072,39 @@ function normalizeAlert(alert) { cleared_at: alert.cleared_at || null, last_evaluated_at: alert.last_evaluated_at || null, details: alert.details || {}, + transition_count: Number(alert.transition_count || 1), + raised_count: Number(alert.raised_count || (alert.status === 'raised' ? 1 : 0)), + cleared_count: Number(alert.cleared_count || (alert.status === 'cleared' ? 1 : 0)), }; } +function summarizeRecentAlertTransitions(alerts) { + const summaries = new Map(); + + for (const alert of alerts || []) { + const key = buildAlertKey(alert); + const existing = summaries.get(key); + if (!existing) { + summaries.set(key, { + ...alert, + transition_count: alert.transition_count || 1, + raised_count: alert.status === 'raised' ? 1 : 0, + cleared_count: alert.status === 'cleared' ? 1 : 0, + }); + continue; + } + + existing.transition_count += 1; + existing.raised_count += alert.status === 'raised' ? 1 : 0; + existing.cleared_count += alert.status === 'cleared' ? 1 : 0; + } + + return [...summaries.values()].sort((left, right) => sortTimestamps( + right.cleared_at || right.raised_at || right.last_evaluated_at, + left.cleared_at || left.raised_at || left.last_evaluated_at, + )); +} + function appendUniqueRecentQuote(quotes, nextQuote, limit) { const deduped = [nextQuote, ...quotes.filter((quote) => quote.quote_id !== nextQuote.quote_id)]; return deduped.slice(0, limit); diff --git a/src/operator-dashboard/static/components/AlertsGrid.jsx b/src/operator-dashboard/static/components/AlertsGrid.jsx index 6030673..9580e10 100644 --- a/src/operator-dashboard/static/components/AlertsGrid.jsx +++ b/src/operator-dashboard/static/components/AlertsGrid.jsx @@ -13,15 +13,31 @@ export default function AlertsGrid({ items, emptyMessage = 'No alerts are active
{item.alert_code} - +
+ + +
{item.reason}
{`Scope ${item.service_scope}`}
-
{formatTimestamp(item.raised_at || item.cleared_at || item.last_evaluated_at)}
+
{formatTransitionTimestamp(item)}
+ {item.transition_count > 1 ? ( +
{`Transitions ${item.transition_count} (raised ${item.raised_count}, cleared ${item.cleared_count})`}
+ ) : null}
))} ); } + +function formatTransitionTimestamp(item) { + if (item.status === 'cleared' && item.cleared_at) { + return `Cleared ${formatTimestamp(item.cleared_at)}`; + } + if (item.raised_at) { + return `Raised ${formatTimestamp(item.raised_at)}`; + } + return formatTimestamp(item.last_evaluated_at); +} diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index 01b6324..c350c7b 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -105,6 +105,10 @@ test('control routing only resolves the allowlisted safe dashboard actions', () service: 'liquidity-manager', action: 'refresh', }); + const resumeExecutor = resolveDashboardControl({ + service: 'trade-executor', + action: 'resume', + }); const risky = resolveDashboardControl({ service: 'strategy-engine', action: 'arm', @@ -112,6 +116,8 @@ test('control routing only resolves the allowlisted safe dashboard actions', () assert.equal(refresh?.path, '/refresh'); assert.equal(refresh?.risk_class, 'safe'); + assert.equal(resumeExecutor?.path, '/resume'); + assert.equal(resumeExecutor?.label, 'Resume Executor Intake'); assert.equal(risky, null); }); @@ -557,6 +563,97 @@ test('ingest disconnected still renders as a critical transport failure', () => assert.match(ingest.health_reasons.join(' '), /websocket disconnected/); }); +test('recent alert history collapses repeated flapping transitions into one readable entry', () => { + const config = buildConfig(); + const bootstrap = buildDashboardBootstrap({ + config, + auth: { + authenticated: true, + subject: 'local-operator', + mode: 'stub', + roles: ['operator'], + }, + portfolioMetric: null, + inventorySnapshot: null, + marketPrice: null, + recentQuotes: [], + successfulTrades: { + page: 1, + page_size: 20, + total: 0, + total_pages: 1, + items: [], + }, + successfulTradeSummary: { + total: 0, + last_successful_trade_at: null, + }, + fundingObservations: [], + recentTradeDecisions: [], + recentAlertTransitions: [], + serviceSnapshots: [ + { + service: 'ops-sentinel', + label: 'Ops Sentinel', + base_url: 'http://ops-sentinel', + reachable: true, + health: { ok: true }, + state: { + active_alerts: [], + recent_transitions: [ + { + alert_code: 'near_intents_quotes_stale', + status: 'raised', + severity: 'critical', + reason: 'quote truth stale', + service_scope: 'near-intents-ingest', + pair: config.activePair, + raised_at: '2026-04-04T09:33:00.000Z', + first_raised_at: '2026-04-04T09:30:00.000Z', + cleared_at: null, + last_evaluated_at: '2026-04-04T09:33:00.000Z', + details: {}, + }, + { + alert_code: 'near_intents_quotes_stale', + status: 'cleared', + severity: 'critical', + reason: 'quote truth stale', + service_scope: 'near-intents-ingest', + pair: config.activePair, + raised_at: '2026-04-04T09:31:00.000Z', + first_raised_at: '2026-04-04T09:30:00.000Z', + cleared_at: '2026-04-04T09:32:00.000Z', + last_evaluated_at: '2026-04-04T09:32:00.000Z', + details: {}, + }, + { + alert_code: 'near_intents_quotes_stale', + status: 'raised', + severity: 'critical', + reason: 'quote truth stale', + service_scope: 'near-intents-ingest', + pair: config.activePair, + raised_at: '2026-04-04T09:31:00.000Z', + first_raised_at: '2026-04-04T09:30:00.000Z', + cleared_at: null, + last_evaluated_at: '2026-04-04T09:31:00.000Z', + details: {}, + }, + ], + }, + }, + ], + }); + + assert.equal(bootstrap.system.alerts.recent.length, 1); + assert.equal(bootstrap.system.alerts.recent[0].alert_code, 'near_intents_quotes_stale'); + assert.equal(bootstrap.system.alerts.recent[0].status, 'raised'); + assert.equal(bootstrap.system.alerts.recent[0].transition_count, 3); + assert.equal(bootstrap.system.alerts.recent[0].raised_count, 2); + assert.equal(bootstrap.system.alerts.recent[0].cleared_count, 1); +}); + test('funding summary includes credited bridge deposits without observer-backed funding observations', () => { const config = buildConfig(); const bootstrap = buildDashboardBootstrap({