diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx index 72ca318..395b0fc 100644 --- a/src/operator-dashboard/static/pages/StrategyPage.jsx +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useMemo, useState } from 'react'; +import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import EmptyState from '../components/EmptyState.jsx'; import MetricCard from '../components/MetricCard.jsx'; @@ -8,6 +8,10 @@ import { formatAgeFromTimestamp, formatBoolean, formatEur, formatTimestamp, trun const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']); const TRADING_PAIR_MODES = new Set(['maker', 'taker', 'both']); +const COMPETITIVENESS_REFRESH_MS = 5_000; +const COMPETITIVENESS_GROUP_ROW_COUNT = 12; +const COMPETITIVENESS_DETAIL_ROW_COUNT = 8; +const COMPETITIVENESS_LATENCY_ROW_COUNT = 6; async function copyIdentifier(value) { if (!value || !navigator?.clipboard?.writeText) return; @@ -270,12 +274,52 @@ function pairDisplayLabel(pairId, pairConfig) { return `${pair.asset_in_symbol || pair.asset_in || pair.assetIn} -> ${pair.asset_out_symbol || pair.asset_out || pair.assetOut}`; } +function fixedRows(items, count) { + const rows = (items || []).slice(0, count); + while (rows.length < count) rows.push(null); + return rows; +} + function MakerCompetitivenessSection({ summary, pairConfig }) { - const total = summary?.total || {}; - const groups = summary?.groups || []; - const ageBuckets = summary?.age_buckets || []; - const latestErrors = summary?.latest_errors || []; - const policySkips = summary?.policy_skips || []; + const latestSummaryRef = useRef(summary || {}); + const [displaySummary, setDisplaySummary] = useState(() => summary || {}); + const [displayUpdatedAt, setDisplayUpdatedAt] = useState(() => new Date().toISOString()); + const [updatesPaused, setUpdatesPaused] = useState(false); + const total = displaySummary?.total || {}; + const groups = displaySummary?.groups || []; + const ageBuckets = displaySummary?.age_buckets || []; + const latestErrors = displaySummary?.latest_errors || []; + const policySkips = displaySummary?.policy_skips || []; + const latencyStages = displaySummary?.latency_stages || []; + const groupRows = fixedRows(groups, COMPETITIVENESS_GROUP_ROW_COUNT); + const ageBucketRows = fixedRows(ageBuckets, COMPETITIVENESS_GROUP_ROW_COUNT); + const latestErrorRows = fixedRows(latestErrors, COMPETITIVENESS_DETAIL_ROW_COUNT); + const policySkipRows = fixedRows(policySkips, COMPETITIVENESS_DETAIL_ROW_COUNT); + const latencyStageRows = fixedRows(latencyStages, COMPETITIVENESS_LATENCY_ROW_COUNT); + + useEffect(() => { + latestSummaryRef.current = summary || {}; + }, [summary]); + + useEffect(() => { + if (updatesPaused) return undefined; + const timer = window.setInterval(() => { + setDisplaySummary(latestSummaryRef.current || {}); + setDisplayUpdatedAt(new Date().toISOString()); + }, COMPETITIVENESS_REFRESH_MS); + return () => window.clearInterval(timer); + }, [updatesPaused]); + + function toggleUpdatesPaused() { + setUpdatesPaused((current) => { + const nextPaused = !current; + if (current) { + setDisplaySummary(latestSummaryRef.current || {}); + setDisplayUpdatedAt(new Date().toISOString()); + } + return nextPaused; + }); + } return (
@@ -290,17 +334,29 @@ function MakerCompetitivenessSection({ summary, pairConfig }) {
0 ? 'warning' : 'unknown'} /> + +
-
+
+ Display snapshot {formatTimestamp(displayUpdatedAt)}. Live updates are applied every {COMPETITIVENESS_REFRESH_MS / 1000}s to keep table height stable. +
+
- - + +
@@ -315,10 +371,17 @@ function MakerCompetitivenessSection({ summary, pairConfig }) { - {groups.length ? groups.slice(0, 12).map((group, index) => { + {groupRows.map((group, index) => { + if (!group) { + return ( + + + + ); + } const quoteToRelay = group.latency_stages?.find((stage) => stage.stage === 'quote_to_relay_result_ms'); return ( - + ); - }) : ( - - )} + })}
Pair
{index === 0 && !groups.length ? 'No competitiveness rows are available yet.' : ''}
{pairDisplayLabel(group.pair, pairConfig)}
{truncateMiddle(group.pair || '', 42)}
@@ -345,16 +408,14 @@ function MakerCompetitivenessSection({ summary, pairConfig }) {
No competitiveness rows are available yet.
-
- - +
+ +
@@ -364,22 +425,29 @@ function MakerCompetitivenessSection({ summary, pairConfig }) { - {(summary?.latency_stages || []).length ? summary.latency_stages.map((stage) => ( - - - - - - - )) : ( - - )} + {latencyStageRows.map((stage, index) => { + if (!stage) { + return ( + + + + ); + } + return ( + + + + + + + ); + })}
Latency stage
{stageLabel(stage.stage)}{formatTimingMs(stage.p50_ms)}{formatTimingMs(stage.p90_ms)}{formatTimingMs(stage.p99_ms)}
No stage timing percentiles are available yet.
{index === 0 && !latencyStages.length ? 'No stage timing percentiles are available yet.' : ''}
{stageLabel(stage.stage)}{formatTimingMs(stage.p50_ms)}{formatTimingMs(stage.p90_ms)}{formatTimingMs(stage.p99_ms)}
- - + +
@@ -389,27 +457,34 @@ function MakerCompetitivenessSection({ summary, pairConfig }) { - {ageBuckets.length ? ageBuckets.slice(0, 12).map((bucket, index) => ( - - - - - - - )) : ( - - )} + {ageBucketRows.map((bucket, index) => { + if (!bucket) { + return ( + + + + ); + } + return ( + + + + + + + ); + })}
Age bucket
-
{bucket.quote_age_bucket}
-
{pairDisplayLabel(bucket.pair, pairConfig)}
-
{plainCodeLabel(bucket.outcome_status)}{bucket.count}{bucket.accepted_count || 0}
No quote-age buckets are available yet.
{index === 0 && !ageBuckets.length ? 'No quote-age buckets are available yet.' : ''}
+
{bucket.quote_age_bucket}
+
{pairDisplayLabel(bucket.pair, pairConfig)}
+
{plainCodeLabel(bucket.outcome_status)}{bucket.count}{bucket.accepted_count || 0}
-
- - +
+ +
@@ -418,28 +493,39 @@ function MakerCompetitivenessSection({ summary, pairConfig }) { - {latestErrors.length ? latestErrors.map((error) => ( - - - - - - )) : ( - - )} + {latestErrorRows.map((error, index) => { + if (!error) { + return ( + + + + ); + } + return ( + + + + + + ); + })}
Latest relay errors
- -
{pairDisplayLabel(error.pair, pairConfig)}
-
{plainCodeLabel(error.failure_category || error.result_code)}
-
-
{formatTimingMs(error.quote_age_ms) || 'Unavailable'}
-
{error.quote_age_bucket}
-
{error.error_message || 'Error text unavailable'}
No relay errors are available yet.
{index === 0 && !latestErrors.length ? 'No relay errors are available yet.' : ''}
+ +
{pairDisplayLabel(error.pair, pairConfig)}
+
{plainCodeLabel(error.failure_category || error.result_code)}
+
+
{formatTimingMs(error.quote_age_ms) || 'Unavailable'}
+
{error.quote_age_bucket}
+
+
+ {error.error_message || 'Error text unavailable'} +
+
- - + +
@@ -448,24 +534,31 @@ function MakerCompetitivenessSection({ summary, pairConfig }) { - {policySkips.length ? policySkips.map((skip) => ( - - - - - - )) : ( - - )} + {policySkipRows.map((skip, index) => { + if (!skip) { + return ( + + + + ); + } + return ( + + + + + + ); + })}
Policy skips
- -
{plainCodeLabel(skip.reason_code)}
-
-
{formatTimingMs(skip.quote_age_ms) || 'Unavailable'}
-
{`max ${formatTimingMs(skip.max_quote_age_ms) || 'Unavailable'}`}
-
-
{skip.pair_config_version ? `v${skip.pair_config_version}` : 'Version unavailable'}
-
{truncateMiddle(skip.pair_config_id || '', 36)}
-
No policy skips are available yet.
{index === 0 && !policySkips.length ? 'No policy skips are available yet.' : ''}
+ +
{plainCodeLabel(skip.reason_code)}
+
+
{formatTimingMs(skip.quote_age_ms) || 'Unavailable'}
+
{`max ${formatTimingMs(skip.max_quote_age_ms) || 'Unavailable'}`}
+
+
{skip.pair_config_version ? `v${skip.pair_config_version}` : 'Version unavailable'}
+
{truncateMiddle(skip.pair_config_id || '', 36)}
+
diff --git a/src/operator-dashboard/static/styles.css b/src/operator-dashboard/static/styles.css index c05478e..9a977ca 100644 --- a/src/operator-dashboard/static/styles.css +++ b/src/operator-dashboard/static/styles.css @@ -216,6 +216,12 @@ select { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } +.metric-stack { + display: grid; + gap: 14px; + grid-template-columns: 1fr; +} + .metric-card { min-width: 0; padding: 16px; @@ -272,6 +278,45 @@ select { margin-top: 14px; } +.competitiveness-snapshot-note { + margin-bottom: 14px; +} + +.competitiveness-table-stack { + margin-top: 14px; +} + +.competitiveness-table { + table-layout: fixed; + min-width: 1040px; +} + +.competitiveness-table tbody tr { + height: 64px; +} + +.competitiveness-detail-table tbody tr { + height: 88px; +} + +.competitiveness-table td { + overflow: hidden; +} + +.competitiveness-placeholder-row td { + height: inherit; + color: var(--muted); +} + +.competitiveness-error-text { + display: -webkit-box; + max-height: 60px; + overflow: hidden; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + .checkbox-row { display: inline-flex; align-items: center; diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index ae4ab6d..e3322c9 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -132,7 +132,18 @@ test('strategy page exposes maker timing waterfall and competitiveness summaries assert.match(strategySource, /maker_competitiveness/); assert.match(strategySource, /pairDisplayLabel/); assert.match(strategySource, /group\.edge_bps/); - assert.match(stylesSource, /\.two-column-grid/); + assert.match(strategySource, /Pause updates/); + assert.match(strategySource, /Resume updates/); + assert.match(strategySource, /displaySummary/); + assert.match(strategySource, /fixedRows/); + assert.match(strategySource, /COMPETITIVENESS_REFRESH_MS/); + assert.match(strategySource, /latencyStageRows/); + assert.match(stylesSource, /\.metric-stack/); + assert.match(stylesSource, /\.competitiveness-table-stack/); + assert.match(stylesSource, /\.competitiveness-table tbody tr/); + assert.match(stylesSource, /\.competitiveness-detail-table tbody tr/); + assert.match(stylesSource, /\.competitiveness-error-text/); + assert.match(stylesSource, /\.competitiveness-placeholder-row td/); assert.match(stylesSource, /\.timing-waterfall-table/); });