Stream quote lifecycle rows to dashboard
All checks were successful
deploy / deploy (push) Successful in 34s
All checks were successful
deploy / deploy (push) Successful in 34s
Proof: npm test; npm run operator-dashboard:build; PYTHONPATH=. python3 test/render_release_manifest_test.py; PYTHONPATH=. python3 test/repo_deployments_test.py Assumptions: Kafka live topics carry normalized quote, decision, command, and execution result envelopes; durable quote outcomes still refresh through history/bootstrap when inventory attribution is recomputed. Still fake: Venue-native terminal fill events and fee-complete realized PnL remain unavailable; submitted and relay-accepted evidence still cannot prove settlement without durable inventory movement.
This commit is contained in:
parent
51461a25bc
commit
ddb360a34f
9 changed files with 475 additions and 33 deletions
|
|
@ -11,6 +11,7 @@ import {
|
||||||
applyDashboardLiveEvent,
|
applyDashboardLiveEvent,
|
||||||
buildDashboardBootstrap,
|
buildDashboardBootstrap,
|
||||||
buildDashboardControlErrorResponse,
|
buildDashboardControlErrorResponse,
|
||||||
|
buildLiveQuoteLifecycleRows,
|
||||||
buildLiveStatusBar,
|
buildLiveStatusBar,
|
||||||
createDashboardLiveState,
|
createDashboardLiveState,
|
||||||
listDashboardServices,
|
listDashboardServices,
|
||||||
|
|
@ -102,10 +103,34 @@ const initialInventory = await safeSourceLoad(
|
||||||
() => loadLatestInventorySnapshot(pool),
|
() => loadLatestInventorySnapshot(pool),
|
||||||
null,
|
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({
|
const liveState = createDashboardLiveState({
|
||||||
config,
|
config,
|
||||||
recentQuotes: initialRecentQuotes,
|
recentQuotes: initialRecentQuotes,
|
||||||
|
recentTradeDecisions: initialRecentTradeDecisions,
|
||||||
|
recentExecuteTradeCommands: initialRecentExecuteTradeCommands,
|
||||||
|
recentExecutionResults: initialRecentExecutionResults,
|
||||||
|
recentQuoteOutcomes: initialRecentQuoteOutcomes,
|
||||||
latestMarketPrice: initialMarketPrice,
|
latestMarketPrice: initialMarketPrice,
|
||||||
latestInventory: initialInventory,
|
latestInventory: initialInventory,
|
||||||
recentSubmissionCount: initialSubmissionSummary.total,
|
recentSubmissionCount: initialSubmissionSummary.total,
|
||||||
|
|
@ -124,6 +149,8 @@ const liveConsumer = await createConsumer({
|
||||||
|
|
||||||
const liveTopics = [
|
const liveTopics = [
|
||||||
config.kafkaTopicNormSwapDemand,
|
config.kafkaTopicNormSwapDemand,
|
||||||
|
config.kafkaTopicDecisionTradeDecision,
|
||||||
|
config.kafkaTopicCmdExecuteTrade,
|
||||||
config.kafkaTopicRefMarketPrice,
|
config.kafkaTopicRefMarketPrice,
|
||||||
config.kafkaTopicStateIntentInventory,
|
config.kafkaTopicStateIntentInventory,
|
||||||
config.kafkaTopicOpsAlert,
|
config.kafkaTopicOpsAlert,
|
||||||
|
|
@ -169,6 +196,7 @@ webSocketServer.on('connection', (socket, _req, authContext) => {
|
||||||
session: authContext,
|
session: authContext,
|
||||||
live: {
|
live: {
|
||||||
recent_quotes: liveState.recent_quotes,
|
recent_quotes: liveState.recent_quotes,
|
||||||
|
recent_lifecycle_rows: buildLiveQuoteLifecycleRows(liveState),
|
||||||
status_bar: buildLiveStatusBar(liveState),
|
status_bar: buildLiveStatusBar(liveState),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { TERMINAL_SETTLEMENT_ATTRIBUTION_STATUSES } from './quote-outcomes.mjs';
|
||||||
import { inferServiceFreshnessTimestamp as inferRuntimeFreshnessTimestamp } from './runtime-health.mjs';
|
import { inferServiceFreshnessTimestamp as inferRuntimeFreshnessTimestamp } from './runtime-health.mjs';
|
||||||
|
|
||||||
export const DASHBOARD_LIVE_QUOTE_LIMIT = 10;
|
export const DASHBOARD_LIVE_QUOTE_LIMIT = 10;
|
||||||
|
export const DASHBOARD_LIVE_LIFECYCLE_LIMIT = 20;
|
||||||
|
|
||||||
const DECIMAL_SCALE = 18;
|
const DECIMAL_SCALE = 18;
|
||||||
const DECIMAL_FACTOR = 10n ** BigInt(DECIMAL_SCALE);
|
const DECIMAL_FACTOR = 10n ** BigInt(DECIMAL_SCALE);
|
||||||
|
|
@ -292,6 +293,10 @@ export function listDashboardServices(config) {
|
||||||
export function createDashboardLiveState({
|
export function createDashboardLiveState({
|
||||||
config,
|
config,
|
||||||
recentQuotes = [],
|
recentQuotes = [],
|
||||||
|
recentTradeDecisions = [],
|
||||||
|
recentExecuteTradeCommands = [],
|
||||||
|
recentExecutionResults = [],
|
||||||
|
recentQuoteOutcomes = [],
|
||||||
latestMarketPrice = null,
|
latestMarketPrice = null,
|
||||||
latestInventory = null,
|
latestInventory = null,
|
||||||
recentSubmissionCount = 0,
|
recentSubmissionCount = 0,
|
||||||
|
|
@ -299,11 +304,17 @@ export function createDashboardLiveState({
|
||||||
activeAlerts = [],
|
activeAlerts = [],
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const state = {
|
const state = {
|
||||||
|
config,
|
||||||
active_pair: config.activePair,
|
active_pair: config.activePair,
|
||||||
btc_asset: config.tradingBtc,
|
btc_asset: config.tradingBtc,
|
||||||
eure_asset: config.tradingEure,
|
eure_asset: config.tradingEure,
|
||||||
quote_limit: config.operatorDashboardQuoteLimit || DASHBOARD_LIVE_QUOTE_LIMIT,
|
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_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_market_price: latestMarketPrice?.payload || latestMarketPrice || null,
|
||||||
latest_inventory: latestInventory?.payload || latestInventory || null,
|
latest_inventory: latestInventory?.payload || latestInventory || null,
|
||||||
recent_submission_count: Number(recentSubmissionCount || 0),
|
recent_submission_count: Number(recentSubmissionCount || 0),
|
||||||
|
|
@ -319,18 +330,62 @@ export function createDashboardLiveState({
|
||||||
return state;
|
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 }) {
|
export function applyDashboardLiveEvent(state, { topic, event }) {
|
||||||
if (!event?.payload) return [];
|
if (!event?.payload) return [];
|
||||||
|
|
||||||
switch (topic) {
|
switch (normalizeDashboardLiveTopic(state, topic)) {
|
||||||
case 'norm.swap_demand': {
|
case 'norm.swap_demand': {
|
||||||
const quote = normalizeLiveQuote(event.payload, event);
|
const quote = normalizeLiveQuote(event.payload, event);
|
||||||
if (!quote) return [];
|
if (!quote) return [];
|
||||||
state.recent_quotes = appendUniqueRecentQuote(state.recent_quotes, quote, state.quote_limit);
|
state.recent_quotes = appendUniqueRecentQuote(state.recent_quotes, quote, state.quote_limit);
|
||||||
return [{
|
return [
|
||||||
|
{
|
||||||
type: 'quotes.recent',
|
type: 'quotes.recent',
|
||||||
recent_quotes: state.recent_quotes,
|
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':
|
case 'ref.market_price':
|
||||||
state.latest_market_price = {
|
state.latest_market_price = {
|
||||||
|
|
@ -355,14 +410,27 @@ export function applyDashboardLiveEvent(state, { topic, event }) {
|
||||||
case 'ops.alert': {
|
case 'ops.alert': {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
case 'exec.trade_result':
|
case 'exec.trade_result': {
|
||||||
if (event.payload.status !== 'submitted') return [];
|
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.recent_submission_count += 1;
|
||||||
state.last_submission_at = event.observed_at || event.ingested_at || new Date().toISOString();
|
state.last_submission_at = event.observed_at || event.ingested_at || new Date().toISOString();
|
||||||
return [{
|
updates.push({
|
||||||
type: 'status_bar.updated',
|
type: 'status_bar.updated',
|
||||||
status_bar: buildLiveStatusBar(state),
|
status_bar: buildLiveStatusBar(state),
|
||||||
}];
|
});
|
||||||
|
}
|
||||||
|
updates.push(buildQuoteLifecycleUpdate(state, { flashQuoteId: execution.quote_id }));
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return [];
|
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) {
|
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);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Pill from './Pill.jsx';
|
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 }) {
|
export default function ServiceCard({ service }) {
|
||||||
const healthLabel = service.health_label || service.health_status || (service.reachable ? 'online' : 'offline');
|
const healthLabel = service.health_label || service.health_status || (service.reachable ? 'online' : 'offline');
|
||||||
|
|
@ -14,7 +14,8 @@ export default function ServiceCard({ service }) {
|
||||||
<div>{`Reachable ${formatBoolean(service.reachable)}`}</div>
|
<div>{`Reachable ${formatBoolean(service.reachable)}`}</div>
|
||||||
<div>{`Paused ${formatBoolean(service.paused)}`}</div>
|
<div>{`Paused ${formatBoolean(service.paused)}`}</div>
|
||||||
<div>{`Armed ${formatBoolean(service.armed)}`}</div>
|
<div>{`Armed ${formatBoolean(service.armed)}`}</div>
|
||||||
<div>{`Freshness ${formatAge(service.freshness_age_ms)}`}</div>
|
<div>{`Freshness ${formatAge(service.freshness_age_ms)}${service.freshness_age_ms == null ? '' : ' ago'}`}</div>
|
||||||
|
<div>{`Freshness at ${formatTimestamp(service.freshness_at)}`}</div>
|
||||||
<div className="mono">{service.base_url}</div>
|
<div className="mono">{service.base_url}</div>
|
||||||
{service.last_error ? <div>{JSON.stringify(service.last_error)}</div> : null}
|
{service.last_error ? <div>{JSON.stringify(service.last_error)}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,36 @@ export function formatTimestamp(value) {
|
||||||
|
|
||||||
export function formatAge(value) {
|
export function formatAge(value) {
|
||||||
if (value == null) return 'Unavailable';
|
if (value == null) return 'Unavailable';
|
||||||
if (value < 1000) return `${value} ms`;
|
const numeric = Number(value);
|
||||||
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
|
if (!Number.isFinite(numeric)) return 'Unavailable';
|
||||||
if (value < 3_600_000) return `${(value / 60_000).toFixed(1)} min`;
|
const ageMs = Math.max(0, Math.floor(numeric));
|
||||||
return `${(value / 3_600_000).toFixed(1)} h`;
|
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) {
|
export function formatEur(value) {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { Fragment, useState } from 'react';
|
import { Fragment, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import EmptyState from '../components/EmptyState.jsx';
|
import EmptyState from '../components/EmptyState.jsx';
|
||||||
import MetricCard from '../components/MetricCard.jsx';
|
import MetricCard from '../components/MetricCard.jsx';
|
||||||
import Pill from '../components/Pill.jsx';
|
import Pill from '../components/Pill.jsx';
|
||||||
import TableFrame from '../components/TableFrame.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']);
|
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 }) {
|
function IdentifierRow({ label, value }) {
|
||||||
if (!value) return <div className="status-subtle">{`${label}: unavailable`}</div>;
|
if (!value) return <div className="status-subtle">{`${label}: unavailable`}</div>;
|
||||||
|
|
||||||
|
|
@ -123,6 +139,7 @@ function LifecycleDetails({ item }) {
|
||||||
|
|
||||||
function QuoteLifecycleTable({ items }) {
|
function QuoteLifecycleTable({ items }) {
|
||||||
const [expanded, setExpanded] = useState(() => new Set());
|
const [expanded, setExpanded] = useState(() => new Set());
|
||||||
|
const now = useNow();
|
||||||
if (!items?.length) return <EmptyState>No quote lifecycle evidence has been observed yet.</EmptyState>;
|
if (!items?.length) return <EmptyState>No quote lifecycle evidence has been observed yet.</EmptyState>;
|
||||||
|
|
||||||
function toggle(rowKey) {
|
function toggle(rowKey) {
|
||||||
|
|
@ -153,13 +170,15 @@ function QuoteLifecycleTable({ items }) {
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const rowKey = item.quote_id || item.decision_id || item.command_id || item.latest_stage_at || String(index);
|
const rowKey = item.quote_id || item.decision_id || item.command_id || item.latest_stage_at || String(index);
|
||||||
const isExpanded = expanded.has(rowKey);
|
const isExpanded = expanded.has(rowKey);
|
||||||
|
const quoteTime = item.quote_activity_at || item.latest_stage_at;
|
||||||
return (
|
return (
|
||||||
<Fragment key={rowKey}>
|
<Fragment key={rowKey}>
|
||||||
<tr key={`${rowKey}:row`}>
|
<tr className={item.live_flash_at ? 'quote-row-flash' : undefined} key={`${rowKey}:row`}>
|
||||||
<td>
|
<td>
|
||||||
<div>{formatTimestamp(item.quote_activity_at || item.latest_stage_at)}</div>
|
<div>{formatTimestamp(quoteTime)}</div>
|
||||||
|
<div className="status-subtle quote-age">{formatRelativeAge(quoteTime, now)}</div>
|
||||||
{item.latest_stage_at && item.latest_stage_at !== item.quote_activity_at ? (
|
{item.latest_stage_at && item.latest_stage_at !== item.quote_activity_at ? (
|
||||||
<div className="status-subtle">Updated {formatTimestamp(item.latest_stage_at)}</div>
|
<div className="status-subtle">Updated {formatTimestamp(item.latest_stage_at)} · {formatRelativeAge(item.latest_stage_at, now)}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td><IdentifierRow label="Quote" value={item.quote_id} /></td>
|
<td><IdentifierRow label="Quote" value={item.quote_id} /></td>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ function applySocketMessage(dashboard, payload, session) {
|
||||||
...dashboard.funds,
|
...dashboard.funds,
|
||||||
recent_quotes: payload.live?.recent_quotes || dashboard.funds.recent_quotes,
|
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: {
|
status_bar: {
|
||||||
...dashboard.status_bar,
|
...dashboard.status_bar,
|
||||||
...(payload.live?.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':
|
case 'status_bar.updated':
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,26 @@ table.lifecycle-table th:nth-child(5) {
|
||||||
width: 150px;
|
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 th:nth-child(2),
|
||||||
.quote-lifecycle-table td:nth-child(2) {
|
.quote-lifecycle-table td:nth-child(2) {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
|
|
|
||||||
|
|
@ -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 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 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 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', () => {
|
test('strategy page owns consolidated quote lifecycle and successful trade tables', () => {
|
||||||
assert.match(strategySource, /Quote lifecycle/);
|
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, /successful_trade_gross_edge_estimate_eure/);
|
||||||
assert.match(strategySource, /before fees/);
|
assert.match(strategySource, /before fees/);
|
||||||
assert.match(strategySource, /Show lifecycle/);
|
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.match(strategySource, /Submitted means the relay accepted the response; it does not prove a trade\./);
|
||||||
assert.doesNotMatch(strategySource, /Actionable|actionable/);
|
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/);
|
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', () => {
|
test('mobile status bar uses normal document flow instead of sticky viewport positioning', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
stylesSource,
|
stylesSource,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import {
|
||||||
resolveDashboardControl,
|
resolveDashboardControl,
|
||||||
resolveDashboardControlTimeoutMs,
|
resolveDashboardControlTimeoutMs,
|
||||||
} from '../src/core/operator-dashboard.mjs';
|
} 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 {
|
import {
|
||||||
buildDashboardSessionToken,
|
buildDashboardSessionToken,
|
||||||
parseBasicAuthorizationHeader,
|
parseBasicAuthorizationHeader,
|
||||||
|
|
@ -35,6 +37,13 @@ function buildConfig() {
|
||||||
return {
|
return {
|
||||||
activePair: `${tradingBtc.assetId}->${tradingEure.assetId}`,
|
activePair: `${tradingBtc.assetId}->${tradingEure.assetId}`,
|
||||||
operatorDashboardQuoteLimit: 10,
|
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,
|
tradingBtc,
|
||||||
tradingEure,
|
tradingEure,
|
||||||
assetRegistry: new Map([
|
assetRegistry: new Map([
|
||||||
|
|
@ -212,7 +221,7 @@ test('basic auth resolves operator identity and reuses a session cookie', () =>
|
||||||
assert.equal(second.via, '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 config = buildConfig();
|
||||||
const state = createDashboardLiveState({
|
const state = createDashboardLiveState({
|
||||||
config,
|
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',
|
lastSubmissionAt: '2026-04-04T08:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let latestQuoteUpdates = [];
|
||||||
for (let index = 0; index < 11; index += 1) {
|
for (let index = 0; index < 11; index += 1) {
|
||||||
applyDashboardLiveEvent(state, {
|
latestQuoteUpdates = applyDashboardLiveEvent(state, {
|
||||||
topic: 'norm.swap_demand',
|
topic: config.kafkaTopicNormSwapDemand,
|
||||||
event: {
|
event: {
|
||||||
observed_at: `2026-04-04T08:00:${String(index).padStart(2, '0')}.000Z`,
|
observed_at: `2026-04-04T08:00:${String(index).padStart(2, '0')}.000Z`,
|
||||||
ingested_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, {
|
const quoteLifecycleUpdate = latestQuoteUpdates.find((update) => update.type === 'quote_lifecycle.updated');
|
||||||
topic: 'exec.trade_result',
|
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: {
|
event: {
|
||||||
observed_at: '2026-04-04T08:30:00.000Z',
|
observed_at: '2026-04-04T08:30:00.000Z',
|
||||||
ingested_at: '2026-04-04T08:30:00.000Z',
|
ingested_at: '2026-04-04T08:30:00.000Z',
|
||||||
payload: {
|
payload: {
|
||||||
|
command_id: 'cmd-quote-10',
|
||||||
|
decision_id: 'decision-quote-10',
|
||||||
|
quote_id: 'quote-10',
|
||||||
|
pair: config.activePair,
|
||||||
status: 'submitted',
|
status: 'submitted',
|
||||||
|
result_code: 'quote_response_ok',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(state.recent_quotes.length, 10);
|
const submittedLifecycleUpdate = submittedUpdates.find((update) => update.type === 'quote_lifecycle.updated');
|
||||||
assert.equal(state.recent_quotes[0].quote_id, 'quote-10');
|
|
||||||
assert.equal(state.recent_quotes.at(-1).quote_id, 'quote-1');
|
|
||||||
assert.equal(state.recent_submission_count, 3);
|
assert.equal(state.recent_submission_count, 3);
|
||||||
assert.equal(state.last_submission_at, '2026-04-04T08:30:00.000Z');
|
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', () => {
|
test('live dashboard ignores ops alert events so alert severity cannot re-enter operator state', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue