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', () => {