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.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({