diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index 14fdefd..25a9b02 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -11,6 +11,7 @@ import { applyDashboardLiveEvent, buildDashboardBootstrap, buildDashboardControlErrorResponse, + buildLiveQuoteLifecycleRows, buildLiveStatusBar, createDashboardLiveState, listDashboardServices, @@ -102,10 +103,34 @@ const initialInventory = await safeSourceLoad( () => loadLatestInventorySnapshot(pool), null, ); +const initialRecentTradeDecisions = await safeSourceLoad( + 'recent_trade_decisions', + () => loadRecentTradeDecisions(pool, { limit: 20 }), + [], +); +const initialRecentExecuteTradeCommands = await safeSourceLoad( + 'recent_execute_trade_commands', + () => loadRecentExecuteTradeCommands(pool, { limit: 40 }), + [], +); +const initialRecentExecutionResults = await safeSourceLoad( + 'recent_execution_results', + () => loadRecentExecutionResults(pool, { limit: 40 }), + [], +); +const initialRecentQuoteOutcomes = await safeSourceLoad( + 'recent_quote_outcomes', + () => loadRecentQuoteOutcomes(pool, { limit: 200 }), + [], +); const liveState = createDashboardLiveState({ config, recentQuotes: initialRecentQuotes, + recentTradeDecisions: initialRecentTradeDecisions, + recentExecuteTradeCommands: initialRecentExecuteTradeCommands, + recentExecutionResults: initialRecentExecutionResults, + recentQuoteOutcomes: initialRecentQuoteOutcomes, latestMarketPrice: initialMarketPrice, latestInventory: initialInventory, recentSubmissionCount: initialSubmissionSummary.total, @@ -124,6 +149,8 @@ const liveConsumer = await createConsumer({ const liveTopics = [ config.kafkaTopicNormSwapDemand, + config.kafkaTopicDecisionTradeDecision, + config.kafkaTopicCmdExecuteTrade, config.kafkaTopicRefMarketPrice, config.kafkaTopicStateIntentInventory, config.kafkaTopicOpsAlert, @@ -169,6 +196,7 @@ webSocketServer.on('connection', (socket, _req, authContext) => { session: authContext, live: { recent_quotes: liveState.recent_quotes, + recent_lifecycle_rows: buildLiveQuoteLifecycleRows(liveState), status_bar: buildLiveStatusBar(liveState), }, })); diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index 33fb751..4da6c5f 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -5,6 +5,7 @@ import { TERMINAL_SETTLEMENT_ATTRIBUTION_STATUSES } from './quote-outcomes.mjs'; import { inferServiceFreshnessTimestamp as inferRuntimeFreshnessTimestamp } from './runtime-health.mjs'; export const DASHBOARD_LIVE_QUOTE_LIMIT = 10; +export const DASHBOARD_LIVE_LIFECYCLE_LIMIT = 20; const DECIMAL_SCALE = 18; const DECIMAL_FACTOR = 10n ** BigInt(DECIMAL_SCALE); @@ -292,6 +293,10 @@ export function listDashboardServices(config) { export function createDashboardLiveState({ config, recentQuotes = [], + recentTradeDecisions = [], + recentExecuteTradeCommands = [], + recentExecutionResults = [], + recentQuoteOutcomes = [], latestMarketPrice = null, latestInventory = null, recentSubmissionCount = 0, @@ -299,11 +304,17 @@ export function createDashboardLiveState({ activeAlerts = [], } = {}) { const state = { + config, active_pair: config.activePair, btc_asset: config.tradingBtc, eure_asset: config.tradingEure, quote_limit: config.operatorDashboardQuoteLimit || DASHBOARD_LIVE_QUOTE_LIMIT, + lifecycle_limit: config.operatorDashboardLifecycleLimit || DASHBOARD_LIVE_LIFECYCLE_LIMIT, recent_quotes: recentQuotes.slice(0, config.operatorDashboardQuoteLimit || DASHBOARD_LIVE_QUOTE_LIMIT), + recent_trade_decisions: recentTradeDecisions.slice(0, config.operatorDashboardLifecycleLimit || DASHBOARD_LIVE_LIFECYCLE_LIMIT), + recent_execute_trade_commands: recentExecuteTradeCommands.slice(0, config.operatorDashboardLifecycleLimit || DASHBOARD_LIVE_LIFECYCLE_LIMIT), + recent_execution_results: recentExecutionResults.slice(0, config.operatorDashboardLifecycleLimit || DASHBOARD_LIVE_LIFECYCLE_LIMIT), + recent_quote_outcomes: recentQuoteOutcomes.slice(0, config.operatorDashboardLifecycleLimit || DASHBOARD_LIVE_LIFECYCLE_LIMIT), latest_market_price: latestMarketPrice?.payload || latestMarketPrice || null, latest_inventory: latestInventory?.payload || latestInventory || null, recent_submission_count: Number(recentSubmissionCount || 0), @@ -319,18 +330,62 @@ export function createDashboardLiveState({ return state; } +export function buildLiveQuoteLifecycleRows(state, { flashQuoteId = null, flashAt = null } = {}) { + const rows = deriveQuoteLifecycleRows({ + recentQuotes: state.recent_quotes, + recentTradeDecisions: state.recent_trade_decisions, + recentExecuteTradeCommands: state.recent_execute_trade_commands, + recentExecutionResults: state.recent_execution_results, + recentQuoteOutcomes: state.recent_quote_outcomes, + limit: state.lifecycle_limit, + }).map((row) => enrichLifecycleRowForUi({ config: state.config, row })); + + if (!flashQuoteId) return rows; + const highlightedAt = flashAt || new Date().toISOString(); + return rows.map((row) => ( + row.quote_id === flashQuoteId + ? { ...row, live_flash_at: highlightedAt } + : row + )); +} + export function applyDashboardLiveEvent(state, { topic, event }) { if (!event?.payload) return []; - switch (topic) { + switch (normalizeDashboardLiveTopic(state, topic)) { case 'norm.swap_demand': { const quote = normalizeLiveQuote(event.payload, event); if (!quote) return []; state.recent_quotes = appendUniqueRecentQuote(state.recent_quotes, quote, state.quote_limit); - return [{ - type: 'quotes.recent', - recent_quotes: state.recent_quotes, - }]; + return [ + { + type: 'quotes.recent', + recent_quotes: state.recent_quotes, + }, + buildQuoteLifecycleUpdate(state, { flashQuoteId: quote.quote_id }), + ]; + } + case 'decision.trade_decision': { + const decision = normalizeLiveDecision(event.payload, event); + if (!decision) return []; + state.recent_trade_decisions = appendUniqueRecentEvent( + state.recent_trade_decisions, + decision, + state.lifecycle_limit, + (entry) => livePayloadKey(entry, ['decision_id', 'quote_id']), + ); + return [buildQuoteLifecycleUpdate(state, { flashQuoteId: decision.payload?.quote_id })]; + } + case 'cmd.execute_trade': { + const command = normalizeLiveCommand(event.payload, event); + if (!command) return []; + state.recent_execute_trade_commands = appendUniqueRecentEvent( + state.recent_execute_trade_commands, + command, + state.lifecycle_limit, + (entry) => livePayloadKey(entry, ['command_id', 'decision_id', 'quote_id']), + ); + return [buildQuoteLifecycleUpdate(state, { flashQuoteId: command.payload?.quote_id })]; } case 'ref.market_price': state.latest_market_price = { @@ -355,14 +410,27 @@ export function applyDashboardLiveEvent(state, { topic, event }) { case 'ops.alert': { return []; } - case 'exec.trade_result': - if (event.payload.status !== 'submitted') return []; - state.recent_submission_count += 1; - state.last_submission_at = event.observed_at || event.ingested_at || new Date().toISOString(); - return [{ - type: 'status_bar.updated', - status_bar: buildLiveStatusBar(state), - }]; + case 'exec.trade_result': { + const execution = normalizeLiveExecutionResult(event.payload, event); + if (!execution) return []; + state.recent_execution_results = appendUniqueRecentEvent( + state.recent_execution_results, + execution, + state.lifecycle_limit, + (entry) => livePayloadKey(entry, ['command_id', 'decision_id', 'quote_id', 'result_at']), + ); + const updates = []; + if (event.payload.status === 'submitted') { + state.recent_submission_count += 1; + state.last_submission_at = event.observed_at || event.ingested_at || new Date().toISOString(); + updates.push({ + type: 'status_bar.updated', + status_bar: buildLiveStatusBar(state), + }); + } + updates.push(buildQuoteLifecycleUpdate(state, { flashQuoteId: execution.quote_id })); + return updates; + } default: return []; } @@ -1819,6 +1887,98 @@ function summarizeRecentAlertTransitions(alerts) { )); } +function normalizeDashboardLiveTopic(state, topic) { + const config = state?.config || {}; + const aliases = new Map([ + [config.kafkaTopicNormSwapDemand, 'norm.swap_demand'], + [config.kafkaTopicDecisionTradeDecision, 'decision.trade_decision'], + [config.kafkaTopicCmdExecuteTrade, 'cmd.execute_trade'], + [config.kafkaTopicRefMarketPrice, 'ref.market_price'], + [config.kafkaTopicStateIntentInventory, 'state.intent_inventory'], + [config.kafkaTopicOpsAlert, 'ops.alert'], + [config.kafkaTopicExecTradeResult, 'exec.trade_result'], + ]); + return aliases.get(topic) || topic; +} + +function buildQuoteLifecycleUpdate(state, { flashQuoteId = null } = {}) { + const receivedAt = new Date().toISOString(); + return { + type: 'quote_lifecycle.updated', + recent_lifecycle_rows: buildLiveQuoteLifecycleRows(state, { + flashQuoteId, + flashAt: receivedAt, + }), + flash_quote_id: flashQuoteId || null, + received_at: receivedAt, + }; +} + +function appendUniqueRecentEvent(items, nextItem, limit, keyFn) { + const nextKey = keyFn(nextItem); + const deduped = [ + nextItem, + ...(items || []).filter((item) => keyFn(item) !== nextKey), + ]; + return deduped.slice(0, limit); +} + +function livePayloadKey(entry, fields) { + const payload = entry?.payload || entry || {}; + for (const field of fields) { + if (payload[field] != null && payload[field] !== '') return `${field}:${payload[field]}`; + } + return `event:${entry?.observed_at || entry?.ingested_at || JSON.stringify(payload)}`; +} + +function normalizeLiveDecision(payload, event) { + if (!payload?.decision_id && !payload?.quote_id) return null; + const decisionAt = payload.decision_at || event.observed_at || event.ingested_at || null; + return { + observed_at: event.observed_at || decisionAt, + ingested_at: event.ingested_at || null, + payload: { + ...payload, + decision_at: decisionAt, + }, + }; +} + +function normalizeLiveCommand(payload, event) { + if (!payload?.command_id && !payload?.decision_id && !payload?.quote_id) return null; + return { + observed_at: event.observed_at || event.ingested_at || null, + ingested_at: event.ingested_at || null, + payload: { + ...payload, + amount_in: payload.quote_output?.amount_in ?? payload.proposed_amount_in ?? payload.amount_in ?? null, + amount_out: payload.quote_output?.amount_out ?? payload.proposed_amount_out ?? payload.amount_out ?? null, + }, + }; +} + +function normalizeLiveExecutionResult(payload, event) { + if (!payload?.command_id && !payload?.decision_id && !payload?.quote_id) return null; + return { + command_id: payload.command_id || null, + decision_id: payload.decision_id || null, + execution_key: payload.execution_key || null, + quote_id: payload.quote_id || null, + pair: payload.pair || null, + result_at: event.observed_at || event.ingested_at || new Date().toISOString(), + status: payload.status || null, + result_code: payload.result_code || null, + outcome_status: payload.outcome_status || payload.venue_outcome_status || payload.trade_outcome_status || null, + outcome_reason: payload.outcome_reason || payload.venue_outcome_reason || payload.trade_outcome_reason || null, + attribution_status: payload.attribution_status || null, + attribution_method: payload.attribution_method || null, + attributed_inventory_delta: payload.attributed_inventory_delta || null, + venue_response: payload.venue_response || null, + error_message: payload.error?.message || null, + note: payload.note || null, + }; +} + 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/ServiceCard.jsx b/src/operator-dashboard/static/components/ServiceCard.jsx index 3ffd624..d398867 100644 --- a/src/operator-dashboard/static/components/ServiceCard.jsx +++ b/src/operator-dashboard/static/components/ServiceCard.jsx @@ -1,5 +1,5 @@ import Pill from './Pill.jsx'; -import { formatAge, formatBoolean } from '../lib/format.js'; +import { formatAge, formatBoolean, formatTimestamp } from '../lib/format.js'; export default function ServiceCard({ service }) { const healthLabel = service.health_label || service.health_status || (service.reachable ? 'online' : 'offline'); @@ -14,7 +14,8 @@ export default function ServiceCard({ service }) {
{`Reachable ${formatBoolean(service.reachable)}`}
{`Paused ${formatBoolean(service.paused)}`}
{`Armed ${formatBoolean(service.armed)}`}
-
{`Freshness ${formatAge(service.freshness_age_ms)}`}
+
{`Freshness ${formatAge(service.freshness_age_ms)}${service.freshness_age_ms == null ? '' : ' ago'}`}
+
{`Freshness at ${formatTimestamp(service.freshness_at)}`}
{service.base_url}
{service.last_error ?
{JSON.stringify(service.last_error)}
: null} diff --git a/src/operator-dashboard/static/lib/format.js b/src/operator-dashboard/static/lib/format.js index c88642e..5d99dd5 100644 --- a/src/operator-dashboard/static/lib/format.js +++ b/src/operator-dashboard/static/lib/format.js @@ -12,10 +12,36 @@ export function formatTimestamp(value) { export function formatAge(value) { if (value == null) return 'Unavailable'; - if (value < 1000) return `${value} ms`; - if (value < 60_000) return `${(value / 1000).toFixed(1)} s`; - if (value < 3_600_000) return `${(value / 60_000).toFixed(1)} min`; - return `${(value / 3_600_000).toFixed(1)} h`; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 'Unavailable'; + const ageMs = Math.max(0, Math.floor(numeric)); + if (ageMs < 1000) return `${ageMs} ms`; + + const seconds = Math.floor(ageMs / 1000); + if (seconds < 60) return `${seconds}s`; + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) { + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; + } + + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + if (hours < 24) { + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; + } + + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`; +} + +export function formatAgeFromTimestamp(value, now = Date.now()) { + if (!value) return 'Unavailable'; + const timestamp = new Date(value).getTime(); + if (Number.isNaN(timestamp)) return 'Unavailable'; + return formatAge(now - timestamp); } export function formatEur(value) { diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index 005348d..63fe621 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -1,10 +1,10 @@ -import { Fragment, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import EmptyState from '../components/EmptyState.jsx'; import MetricCard from '../components/MetricCard.jsx'; import Pill from '../components/Pill.jsx'; import TableFrame from '../components/TableFrame.jsx'; -import { formatBoolean, formatEur, formatTimestamp, truncateMiddle } from '../lib/format.js'; +import { formatAgeFromTimestamp, formatBoolean, formatEur, formatTimestamp, truncateMiddle } from '../lib/format.js'; const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']); @@ -17,6 +17,22 @@ async function copyIdentifier(value) { } } +function useNow(intervalMs = 1000) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), intervalMs); + return () => window.clearInterval(timer); + }, [intervalMs]); + + return now; +} + +function formatRelativeAge(value, now) { + const age = formatAgeFromTimestamp(value, now); + return age === 'Unavailable' ? 'Age unavailable' : `${age} ago`; +} + function IdentifierRow({ label, value }) { if (!value) return
{`${label}: unavailable`}
; @@ -123,6 +139,7 @@ function LifecycleDetails({ item }) { function QuoteLifecycleTable({ items }) { const [expanded, setExpanded] = useState(() => new Set()); + const now = useNow(); if (!items?.length) return No quote lifecycle evidence has been observed yet.; function toggle(rowKey) { @@ -153,13 +170,15 @@ function QuoteLifecycleTable({ items }) { {items.map((item, index) => { const rowKey = item.quote_id || item.decision_id || item.command_id || item.latest_stage_at || String(index); const isExpanded = expanded.has(rowKey); + const quoteTime = item.quote_activity_at || item.latest_stage_at; return ( - + -
{formatTimestamp(item.quote_activity_at || item.latest_stage_at)}
+
{formatTimestamp(quoteTime)}
+
{formatRelativeAge(quoteTime, now)}
{item.latest_stage_at && item.latest_stage_at !== item.quote_activity_at ? ( -
Updated {formatTimestamp(item.latest_stage_at)}
+
Updated {formatTimestamp(item.latest_stage_at)} ยท {formatRelativeAge(item.latest_stage_at, now)}
) : null} diff --git a/src/operator-dashboard/static/state/dashboardReducer.js b/src/operator-dashboard/static/state/dashboardReducer.js index 8aca50b..2080bc2 100644 --- a/src/operator-dashboard/static/state/dashboardReducer.js +++ b/src/operator-dashboard/static/state/dashboardReducer.js @@ -11,6 +11,13 @@ function applySocketMessage(dashboard, payload, session) { ...dashboard.funds, recent_quotes: payload.live?.recent_quotes || dashboard.funds.recent_quotes, }, + strategy: payload.live?.recent_lifecycle_rows ? { + ...dashboard.strategy, + strategy_state: { + ...dashboard.strategy.strategy_state, + recent_lifecycle_rows: payload.live.recent_lifecycle_rows, + }, + } : dashboard.strategy, status_bar: { ...dashboard.status_bar, ...(payload.live?.status_bar || {}), @@ -28,6 +35,22 @@ function applySocketMessage(dashboard, payload, session) { }, }, }; + case 'quote_lifecycle.updated': + return { + session, + dashboard: { + ...dashboard, + strategy: { + ...dashboard.strategy, + strategy_state: { + ...dashboard.strategy.strategy_state, + recent_lifecycle_rows: + payload.recent_lifecycle_rows + || dashboard.strategy.strategy_state.recent_lifecycle_rows, + }, + }, + }, + }; case 'status_bar.updated': return { session, diff --git a/src/operator-dashboard/static/styles.css b/src/operator-dashboard/static/styles.css index 62663b6..248ab67 100644 --- a/src/operator-dashboard/static/styles.css +++ b/src/operator-dashboard/static/styles.css @@ -549,6 +549,26 @@ table.lifecycle-table th:nth-child(5) { width: 150px; } +.quote-age { + font-variant-numeric: tabular-nums; +} + +.quote-row-flash td { + animation: quote-row-flash-cell 1800ms ease-out; +} + +@keyframes quote-row-flash-cell { + 0% { + background: rgba(31, 122, 90, 0.24); + box-shadow: inset 4px 0 0 rgba(31, 122, 90, 0.72); + } + + 100% { + background: transparent; + box-shadow: inset 0 0 0 rgba(31, 122, 90, 0); + } +} + .quote-lifecycle-table th:nth-child(2), .quote-lifecycle-table td:nth-child(2) { width: 260px; diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index c0beb47..2f4c119 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs'; const strategySource = readFileSync(new URL('../src/operator-dashboard/static/pages/StrategyPage.jsx', import.meta.url), 'utf8'); const fundsSource = readFileSync(new URL('../src/operator-dashboard/static/pages/FundsPage.jsx', import.meta.url), 'utf8'); const stylesSource = readFileSync(new URL('../src/operator-dashboard/static/styles.css', import.meta.url), 'utf8'); +const serviceCardSource = readFileSync(new URL('../src/operator-dashboard/static/components/ServiceCard.jsx', import.meta.url), 'utf8'); test('strategy page owns consolidated quote lifecycle and successful trade tables', () => { assert.match(strategySource, /Quote lifecycle/); @@ -15,6 +16,8 @@ test('strategy page owns consolidated quote lifecycle and successful trade table assert.match(strategySource, /successful_trade_gross_edge_estimate_eure/); assert.match(strategySource, /before fees/); assert.match(strategySource, /Show lifecycle/); + assert.match(strategySource, /formatAgeFromTimestamp/); + assert.match(strategySource, /quote-row-flash/); assert.match(strategySource, /Submitted means the relay accepted the response; it does not prove a trade\./); assert.doesNotMatch(strategySource, /Actionable|actionable/); }); @@ -26,6 +29,13 @@ test('funds page no longer renders duplicate quote and submission tables', () => assert.doesNotMatch(fundsSource, /Durable ledger/); }); +test('dashboard freshness surfaces show age and exact timestamp evidence', () => { + assert.match(serviceCardSource, /formatTimestamp\(service\.freshness_at\)/); + assert.match(serviceCardSource, /Freshness at/); + assert.match(stylesSource, /\.quote-row-flash td/); + assert.match(stylesSource, /@keyframes quote-row-flash-cell/); +}); + test('mobile status bar uses normal document flow instead of sticky viewport positioning', () => { assert.match( stylesSource, diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index ef0aa44..ff27d8c 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -12,6 +12,8 @@ import { resolveDashboardControl, resolveDashboardControlTimeoutMs, } from '../src/core/operator-dashboard.mjs'; +import { formatAge, formatAgeFromTimestamp } from '../src/operator-dashboard/static/lib/format.js'; +import { dashboardReducer } from '../src/operator-dashboard/static/state/dashboardReducer.js'; import { buildDashboardSessionToken, parseBasicAuthorizationHeader, @@ -35,6 +37,13 @@ function buildConfig() { return { activePair: `${tradingBtc.assetId}->${tradingEure.assetId}`, operatorDashboardQuoteLimit: 10, + kafkaTopicNormSwapDemand: 'norm.swap_demand', + kafkaTopicDecisionTradeDecision: 'decision.trade_decision', + kafkaTopicCmdExecuteTrade: 'cmd.execute_trade', + kafkaTopicRefMarketPrice: 'ref.market_price', + kafkaTopicStateIntentInventory: 'state.intent_inventory', + kafkaTopicOpsAlert: 'ops.alert', + kafkaTopicExecTradeResult: 'exec.trade_result', tradingBtc, tradingEure, assetRegistry: new Map([ @@ -212,7 +221,7 @@ test('basic auth resolves operator identity and reuses a session cookie', () => assert.equal(second.via, 'session_cookie'); }); -test('live quote updates stay capped at ten items and submitted results update live counters', () => { +test('live quote updates stay capped and publish lifecycle rows without refresh', () => { const config = buildConfig(); const state = createDashboardLiveState({ config, @@ -220,9 +229,10 @@ test('live quote updates stay capped at ten items and submitted results update l lastSubmissionAt: '2026-04-04T08:00:00.000Z', }); + let latestQuoteUpdates = []; for (let index = 0; index < 11; index += 1) { - applyDashboardLiveEvent(state, { - topic: 'norm.swap_demand', + latestQuoteUpdates = applyDashboardLiveEvent(state, { + topic: config.kafkaTopicNormSwapDemand, event: { observed_at: `2026-04-04T08:00:${String(index).padStart(2, '0')}.000Z`, ingested_at: `2026-04-04T08:00:${String(index).padStart(2, '0')}.000Z`, @@ -239,23 +249,168 @@ test('live quote updates stay capped at ten items and submitted results update l }); } - const updates = applyDashboardLiveEvent(state, { - topic: 'exec.trade_result', + const quoteLifecycleUpdate = latestQuoteUpdates.find((update) => update.type === 'quote_lifecycle.updated'); + assert.equal(state.recent_quotes.length, 10); + assert.equal(state.recent_quotes[0].quote_id, 'quote-10'); + assert.equal(state.recent_quotes.at(-1).quote_id, 'quote-1'); + assert.equal(latestQuoteUpdates[0].type, 'quotes.recent'); + assert.equal(quoteLifecycleUpdate.recent_lifecycle_rows[0].quote_id, 'quote-10'); + assert.equal(quoteLifecycleUpdate.recent_lifecycle_rows[0].lifecycle_state, 'observed'); + assert.ok(quoteLifecycleUpdate.recent_lifecycle_rows[0].live_flash_at); + + const submittedUpdates = applyDashboardLiveEvent(state, { + topic: config.kafkaTopicExecTradeResult, event: { observed_at: '2026-04-04T08:30:00.000Z', ingested_at: '2026-04-04T08:30:00.000Z', payload: { + command_id: 'cmd-quote-10', + decision_id: 'decision-quote-10', + quote_id: 'quote-10', + pair: config.activePair, status: 'submitted', + result_code: 'quote_response_ok', }, }, }); - assert.equal(state.recent_quotes.length, 10); - assert.equal(state.recent_quotes[0].quote_id, 'quote-10'); - assert.equal(state.recent_quotes.at(-1).quote_id, 'quote-1'); + const submittedLifecycleUpdate = submittedUpdates.find((update) => update.type === 'quote_lifecycle.updated'); assert.equal(state.recent_submission_count, 3); assert.equal(state.last_submission_at, '2026-04-04T08:30:00.000Z'); - assert.equal(updates[0].type, 'status_bar.updated'); + assert.ok(submittedUpdates.find((update) => update.type === 'status_bar.updated')); + assert.equal(submittedLifecycleUpdate.recent_lifecycle_rows[0].quote_id, 'quote-10'); + assert.equal(submittedLifecycleUpdate.recent_lifecycle_rows[0].lifecycle_state, 'submitted'); + assert.doesNotMatch( + `${submittedLifecycleUpdate.recent_lifecycle_rows[0].lifecycle_label} ${submittedLifecycleUpdate.recent_lifecycle_rows[0].reason_text}`, + /completed|successful trade|asset delta/i, + ); +}); + +test('live decision, command, and executor result events advance lifecycle rows without bootstrap reload', () => { + const config = buildConfig(); + const state = createDashboardLiveState({ config }); + + applyDashboardLiveEvent(state, { + topic: config.kafkaTopicNormSwapDemand, + event: { + observed_at: '2026-04-04T09:00:00.000Z', + ingested_at: '2026-04-04T09:00:00.000Z', + payload: { + quote_id: 'quote-live', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingEure.assetId, + amount_in: '100', + amount_out: '200', + }, + }, + }); + + const decisionUpdates = applyDashboardLiveEvent(state, { + topic: config.kafkaTopicDecisionTradeDecision, + event: { + observed_at: '2026-04-04T09:00:01.000Z', + ingested_at: '2026-04-04T09:00:01.000Z', + payload: { + decision_id: 'decision-live', + quote_id: 'quote-live', + pair: config.activePair, + decision: 'actionable', + decision_reason: 'actionable', + gross_edge_pct: '0.49', + eure_notional: '5', + }, + }, + }); + assert.equal(decisionUpdates[0].recent_lifecycle_rows[0].lifecycle_state, 'evaluated'); + assert.equal(decisionUpdates[0].recent_lifecycle_rows[0].reason_code, 'strategy_approved'); + + const commandUpdates = applyDashboardLiveEvent(state, { + topic: config.kafkaTopicCmdExecuteTrade, + event: { + observed_at: '2026-04-04T09:00:02.000Z', + ingested_at: '2026-04-04T09:00:02.000Z', + payload: { + command_id: 'cmd-live', + decision_id: 'decision-live', + quote_id: 'quote-live', + pair: config.activePair, + asset_in: config.tradingBtc.assetId, + asset_out: config.tradingEure.assetId, + quote_output: { + amount_in: '101', + amount_out: '201', + }, + }, + }, + }); + assert.equal(commandUpdates[0].recent_lifecycle_rows[0].lifecycle_state, 'command_emitted'); + assert.equal(commandUpdates[0].recent_lifecycle_rows[0].submitted_terms.amount_in_units, '101'); + + const blockedUpdates = applyDashboardLiveEvent(state, { + topic: config.kafkaTopicExecTradeResult, + event: { + observed_at: '2026-04-04T09:00:03.000Z', + ingested_at: '2026-04-04T09:00:03.000Z', + payload: { + command_id: 'cmd-live', + decision_id: 'decision-live', + quote_id: 'quote-live', + pair: config.activePair, + status: 'rejected', + result_code: 'executor_disarmed', + note: 'executor is disarmed', + }, + }, + }); + + assert.equal(blockedUpdates.length, 1); + assert.equal(blockedUpdates[0].type, 'quote_lifecycle.updated'); + assert.equal(blockedUpdates[0].recent_lifecycle_rows[0].lifecycle_state, 'blocked'); + assert.equal(blockedUpdates[0].recent_lifecycle_rows[0].lifecycle_label, 'Blocked before submit'); + assert.notEqual(blockedUpdates[0].recent_lifecycle_rows[0].lifecycle_label, 'Rejected by strategy'); +}); + +test('socket lifecycle messages replace strategy rows without page refresh', () => { + const dashboard = { + funds: { recent_quotes: [] }, + status_bar: {}, + strategy: { + strategy_state: { + recent_lifecycle_rows: [], + }, + }, + }; + const state = { + dashboard, + session: { authenticated: true }, + page: 'strategy', + }; + + const next = dashboardReducer(state, { + type: 'socket.message.received', + payload: { + type: 'quote_lifecycle.updated', + recent_lifecycle_rows: [{ + quote_id: 'quote-live', + lifecycle_state: 'observed', + live_flash_at: '2026-04-04T09:00:00.000Z', + }], + }, + }); + + assert.equal(next.dashboard.strategy.strategy_state.recent_lifecycle_rows[0].quote_id, 'quote-live'); + assert.equal(next.dashboard.strategy.strategy_state.recent_lifecycle_rows[0].live_flash_at, '2026-04-04T09:00:00.000Z'); +}); + +test('dashboard age formatting uses seconds first and exact timestamp deltas', () => { + assert.equal(formatAge(999), '999 ms'); + assert.equal(formatAge(1_500), '1s'); + assert.equal(formatAge(65_000), '1m 5s'); + assert.equal( + formatAgeFromTimestamp('2026-04-04T08:00:00.000Z', Date.parse('2026-04-04T08:01:05.000Z')), + '1m 5s', + ); }); test('live dashboard ignores ops alert events so alert severity cannot re-enter operator state', () => {