Clarify executor controls and alert history
All checks were successful
deploy / deploy (push) Successful in 36s
All checks were successful
deploy / deploy (push) Successful in 36s
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.
This commit is contained in:
parent
69be378784
commit
3c1ad1dde4
3 changed files with 151 additions and 8 deletions
|
|
@ -130,8 +130,8 @@ const CONTROL_DEFINITIONS = [
|
||||||
action: 'pause',
|
action: 'pause',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/pause',
|
path: '/pause',
|
||||||
label: 'Pause Executor',
|
label: 'Pause Executor Intake',
|
||||||
description: 'Pause trade-executor command consumption without moving funds.',
|
description: 'Pause trade-executor command consumption without changing armed state.',
|
||||||
page: 'system',
|
page: 'system',
|
||||||
risk_class: 'safe',
|
risk_class: 'safe',
|
||||||
},
|
},
|
||||||
|
|
@ -140,8 +140,8 @@ const CONTROL_DEFINITIONS = [
|
||||||
action: 'resume',
|
action: 'resume',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/resume',
|
path: '/resume',
|
||||||
label: 'Resume Executor',
|
label: 'Resume Executor Intake',
|
||||||
description: 'Resume trade-executor command consumption.',
|
description: 'Resume trade-executor command consumption without changing armed state.',
|
||||||
page: 'system',
|
page: 'system',
|
||||||
risk_class: 'safe',
|
risk_class: 'safe',
|
||||||
},
|
},
|
||||||
|
|
@ -329,11 +329,11 @@ export function buildDashboardBootstrap({
|
||||||
const activeAlerts = normalizeAlertList(
|
const activeAlerts = normalizeAlertList(
|
||||||
servicesByName['ops-sentinel']?.state?.active_alerts || [],
|
servicesByName['ops-sentinel']?.state?.active_alerts || [],
|
||||||
);
|
);
|
||||||
const recentAlerts = normalizeAlertList(
|
const recentAlerts = summarizeRecentAlertTransitions(normalizeAlertList(
|
||||||
servicesByName['ops-sentinel']?.state?.recent_transitions
|
servicesByName['ops-sentinel']?.state?.recent_transitions
|
||||||
|| recentAlertTransitions?.map((entry) => entry.payload)
|
|| recentAlertTransitions?.map((entry) => entry.payload)
|
||||||
|| [],
|
|| [],
|
||||||
);
|
));
|
||||||
const profitability = buildProfitabilitySummary({
|
const profitability = buildProfitabilitySummary({
|
||||||
metric: portfolioMetric,
|
metric: portfolioMetric,
|
||||||
successfulTradeSummary,
|
successfulTradeSummary,
|
||||||
|
|
@ -1072,9 +1072,39 @@ function normalizeAlert(alert) {
|
||||||
cleared_at: alert.cleared_at || null,
|
cleared_at: alert.cleared_at || null,
|
||||||
last_evaluated_at: alert.last_evaluated_at || null,
|
last_evaluated_at: alert.last_evaluated_at || null,
|
||||||
details: alert.details || {},
|
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) {
|
function appendUniqueRecentQuote(quotes, nextQuote, limit) {
|
||||||
const deduped = [nextQuote, ...quotes.filter((quote) => quote.quote_id !== nextQuote.quote_id)];
|
const deduped = [nextQuote, ...quotes.filter((quote) => quote.quote_id !== nextQuote.quote_id)];
|
||||||
return deduped.slice(0, limit);
|
return deduped.slice(0, limit);
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,31 @@ export default function AlertsGrid({ items, emptyMessage = 'No alerts are active
|
||||||
<div className="service-card" key={`${item.alert_code}:${item.raised_at || item.cleared_at || index}`}>
|
<div className="service-card" key={`${item.alert_code}:${item.raised_at || item.cleared_at || index}`}>
|
||||||
<div className="service-head">
|
<div className="service-head">
|
||||||
<strong>{item.alert_code}</strong>
|
<strong>{item.alert_code}</strong>
|
||||||
<Pill label={item.severity} stateLabel={item.severity} />
|
<div className="pills">
|
||||||
|
<Pill label={item.status || 'unknown'} stateLabel={item.status || 'unknown'} />
|
||||||
|
<Pill label={item.severity} stateLabel={item.severity} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="service-detail">
|
<div className="service-detail">
|
||||||
<div>{item.reason}</div>
|
<div>{item.reason}</div>
|
||||||
<div>{`Scope ${item.service_scope}`}</div>
|
<div>{`Scope ${item.service_scope}`}</div>
|
||||||
<div>{formatTimestamp(item.raised_at || item.cleared_at || item.last_evaluated_at)}</div>
|
<div>{formatTransitionTimestamp(item)}</div>
|
||||||
|
{item.transition_count > 1 ? (
|
||||||
|
<div>{`Transitions ${item.transition_count} (raised ${item.raised_count}, cleared ${item.cleared_count})`}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,10 @@ test('control routing only resolves the allowlisted safe dashboard actions', ()
|
||||||
service: 'liquidity-manager',
|
service: 'liquidity-manager',
|
||||||
action: 'refresh',
|
action: 'refresh',
|
||||||
});
|
});
|
||||||
|
const resumeExecutor = resolveDashboardControl({
|
||||||
|
service: 'trade-executor',
|
||||||
|
action: 'resume',
|
||||||
|
});
|
||||||
const risky = resolveDashboardControl({
|
const risky = resolveDashboardControl({
|
||||||
service: 'strategy-engine',
|
service: 'strategy-engine',
|
||||||
action: 'arm',
|
action: 'arm',
|
||||||
|
|
@ -112,6 +116,8 @@ test('control routing only resolves the allowlisted safe dashboard actions', ()
|
||||||
|
|
||||||
assert.equal(refresh?.path, '/refresh');
|
assert.equal(refresh?.path, '/refresh');
|
||||||
assert.equal(refresh?.risk_class, 'safe');
|
assert.equal(refresh?.risk_class, 'safe');
|
||||||
|
assert.equal(resumeExecutor?.path, '/resume');
|
||||||
|
assert.equal(resumeExecutor?.label, 'Resume Executor Intake');
|
||||||
assert.equal(risky, null);
|
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/);
|
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', () => {
|
test('funding summary includes credited bridge deposits without observer-backed funding observations', () => {
|
||||||
const config = buildConfig();
|
const config = buildConfig();
|
||||||
const bootstrap = buildDashboardBootstrap({
|
const bootstrap = buildDashboardBootstrap({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue