diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index 395b0fc..c06bb35 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -12,6 +12,8 @@ const COMPETITIVENESS_REFRESH_MS = 5_000; const COMPETITIVENESS_GROUP_ROW_COUNT = 12; const COMPETITIVENESS_DETAIL_ROW_COUNT = 8; const COMPETITIVENESS_LATENCY_ROW_COUNT = 6; +const QUOTE_LIFECYCLE_REFRESH_MS = 5_000; +const QUOTE_LIFECYCLE_ROW_COUNT = 20; async function copyIdentifier(value) { if (!value || !navigator?.clipboard?.writeText) return; @@ -22,17 +24,6 @@ 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`; @@ -570,18 +561,26 @@ function MakerCompetitivenessSection({ summary, pairConfig }) { function QuoteLifecycleTable({ items }) { const [expanded, setExpanded] = useState(() => new Set()); const [showStrategyRejected, setShowStrategyRejected] = useState(true); + const latestItemsRef = useRef(items || []); const [quoteDisplayPaused, setQuoteDisplayPaused] = useState(false); const [displayItems, setDisplayItems] = useState(() => items || []); - const liveNow = useNow(); const [displayNow, setDisplayNow] = useState(() => Date.now()); + const [displayUpdatedAt, setDisplayUpdatedAt] = useState(() => new Date().toISOString()); useEffect(() => { - if (!quoteDisplayPaused) setDisplayItems(items || []); - }, [items, quoteDisplayPaused]); + latestItemsRef.current = items || []; + }, [items]); useEffect(() => { - if (!quoteDisplayPaused) setDisplayNow(liveNow); - }, [liveNow, quoteDisplayPaused]); + if (quoteDisplayPaused) return undefined; + const timer = window.setInterval(() => { + const now = Date.now(); + setDisplayItems(latestItemsRef.current || []); + setDisplayNow(now); + setDisplayUpdatedAt(new Date(now).toISOString()); + }, QUOTE_LIFECYCLE_REFRESH_MS); + return () => window.clearInterval(timer); + }, [quoteDisplayPaused]); const rejectedCount = useMemo( () => displayItems.filter((item) => isStrategyRejected(item)).length, @@ -591,6 +590,12 @@ function QuoteLifecycleTable({ items }) { () => (showStrategyRejected ? displayItems : displayItems.filter((item) => !isStrategyRejected(item))), [displayItems, showStrategyRejected], ); + const visibleRows = fixedRows(visibleItems, QUOTE_LIFECYCLE_ROW_COUNT); + const emptyRowsMessage = !displayItems.length + ? 'No quote lifecycle evidence has been observed yet.' + : !visibleItems.length + ? 'No quote lifecycle rows match the current filters.' + : ''; function toggle(rowKey) { setExpanded((current) => { @@ -601,6 +606,18 @@ function QuoteLifecycleTable({ items }) { }); } + function applyLatestLifecycleDisplay() { + const now = Date.now(); + setDisplayItems(latestItemsRef.current || []); + setDisplayNow(now); + setDisplayUpdatedAt(new Date(now).toISOString()); + } + + function toggleQuoteDisplayPaused() { + if (quoteDisplayPaused) applyLatestLifecycleDisplay(); + setQuoteDisplayPaused((paused) => !paused); + } + return ( <>
@@ -615,82 +632,95 @@ function QuoteLifecycleTable({ items }) { - {quoteDisplayPaused ? : null} + +
+
+ Display snapshot {formatTimestamp(displayUpdatedAt)}. Live rows are applied every {QUOTE_LIFECYCLE_REFRESH_MS / 1000}s to keep table height stable.
- {!displayItems.length ? ( - No quote lifecycle evidence has been observed yet. - ) : !visibleItems.length ? ( - No quote lifecycle rows match the current filters. - ) : ( - - - - - - - - - - - - - - - - {visibleItems.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; + +
Quote timeQuote idRequestResponded?ResultReasonGross edge / notionalLifecycle
+ + + + + + + + + + + + + + {visibleRows.map((item, index) => { + if (!item) { return ( - - - - - - - - - - - - {isExpanded ? ( - - - - ) : null} - + + + ); - })} - -
Quote timeQuote idRequestResponded?ResultReasonGross edge / notionalLifecycle
-
{formatTimestamp(quoteTime)}
-
{formatRelativeAge(quoteTime, displayNow)}
- {item.latest_stage_at && item.latest_stage_at !== item.quote_activity_at ? ( -
Updated {formatTimestamp(item.latest_stage_at)} ยท {formatRelativeAge(item.latest_stage_at, displayNow)}
- ) : null} -
-
{formatTerms(item.request_terms || item.submitted_terms)}
-
{truncateMiddle(item.pair || '', 34)}
-
{responseLabel(item)} -
{item.reason_text}
-
{item.reason_code || 'reason_unknown'}
-
-
0 ? 'value-positive' : Number(item.gross_edge_pct) < 0 ? 'value-negative' : ''}>{formatGrossEdgePct(item.gross_edge_pct)}
-
{formatConfiguredEdgeBps(item.edge_bps)}
-
{notionalLabel(item)}
-
- -
{index === 0 ? emptyRowsMessage : ''}
-
- )} + } + 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; + const updatedText = item.latest_stage_at && item.latest_stage_at !== item.quote_activity_at + ? `Updated ${formatTimestamp(item.latest_stage_at)} - ${formatRelativeAge(item.latest_stage_at, displayNow)}` + : ''; + return ( + + + +
+
{formatTimestamp(quoteTime)}
+
{formatRelativeAge(quoteTime, displayNow)}
+
{updatedText}
+
+ + + +
+
{formatTerms(item.request_terms || item.submitted_terms)}
+
{truncateMiddle(item.pair || '', 34)}
+
+ + {responseLabel(item)} + + +
+
{item.reason_text}
+
{item.reason_code || 'reason_unknown'}
+
+ + +
+
0 ? 'value-positive' : Number(item.gross_edge_pct) < 0 ? 'value-negative' : ''}`}>{formatGrossEdgePct(item.gross_edge_pct)}
+
{formatConfiguredEdgeBps(item.edge_bps)}
+
{notionalLabel(item)}
+
+ + + + + + {isExpanded ? ( + + + + ) : null} +
+ ); + })} + + + ); } diff --git a/src/operator-dashboard/static/styles.css b/src/operator-dashboard/static/styles.css index 9a977ca..ca970cb 100644 --- a/src/operator-dashboard/static/styles.css +++ b/src/operator-dashboard/static/styles.css @@ -616,6 +616,10 @@ table.lifecycle-table th:nth-child(5) { margin-bottom: 12px; } +.quote-lifecycle-snapshot-note { + margin-bottom: 12px; +} + .toggle-field { display: inline-flex; align-items: center; @@ -640,6 +644,63 @@ table.lifecycle-table th:nth-child(5) { width: 150px; } +.quote-lifecycle-table tbody tr.quote-lifecycle-row, +.quote-lifecycle-table tbody tr.quote-lifecycle-placeholder-row { + height: 112px; +} + +.quote-lifecycle-table td { + overflow: hidden; +} + +.quote-lifecycle-table .trace-row { + align-items: center; + min-width: 0; +} + +.quote-lifecycle-table .trace-id { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quote-lifecycle-table .pill { + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quote-lifecycle-placeholder-row td { + color: var(--muted); +} + +.quote-lifecycle-cell { + display: grid; + gap: 4px; + min-width: 0; +} + +.lifecycle-line { + min-height: 1.35em; +} + +.lifecycle-clamp-one { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lifecycle-clamp-two { + display: -webkit-box; + max-height: 2.7em; + overflow: hidden; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .quote-age { font-variant-numeric: tabular-nums; } diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index e3322c9..bccc565 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -27,7 +27,15 @@ test('strategy page owns consolidated quote lifecycle and successful trade table assert.match(strategySource, /quoteDisplayPaused/); assert.match(strategySource, /Pause display/); assert.match(strategySource, /Resume display/); - assert.match(strategySource, /setDisplayItems\(items \|\| \[\]\)/); + assert.match(strategySource, /QUOTE_LIFECYCLE_REFRESH_MS/); + assert.match(strategySource, /QUOTE_LIFECYCLE_ROW_COUNT/); + assert.match(strategySource, /latestItemsRef/); + assert.match(strategySource, /visibleRows/); + assert.match(strategySource, /quote-lifecycle-placeholder-row/); + assert.match(stylesSource, /\.quote-lifecycle-table tbody tr\.quote-lifecycle-row/); + assert.match(stylesSource, /\.quote-lifecycle-placeholder-row td/); + assert.match(stylesSource, /\.lifecycle-clamp-one/); + assert.match(stylesSource, /\.lifecycle-clamp-two/); assert.match(strategySource, /item\.execution\?\.error_message/); assert.match(strategySource, /item\.execution\?\.note/); assert.match(strategySource, /formatExecutionTiming/);