From 903287ec21c5edb899f84b502f699970afff6f4f Mon Sep 17 00:00:00 2001 From: philipp Date: Wed, 8 Apr 2026 19:41:43 +0200 Subject: [PATCH] Ship full operator dashboard frontend Proof: Include the full operator-dashboard frontend source tree so the deployment image can build and serve the dashboard UI that consumes runtime health severity. Assumptions: The current static dashboard source tree in the worktree is the intended frontend for this turn and belongs with the deployed operator-dashboard backend. Still fake: External alert receiver remains unconfigured; this commit only resolves the dashboard asset source gap in the deploy. --- .../static/components/AlertsGrid.jsx | 27 + .../static/components/EmptyState.jsx | 3 + .../static/components/MetricCard.jsx | 25 + .../static/components/NavRail.jsx | 38 ++ .../static/components/Pill.jsx | 23 + .../static/components/StatusBar.jsx | 52 ++ .../static/components/TableFrame.jsx | 8 + src/operator-dashboard/static/index.html | 12 + src/operator-dashboard/static/lib/api.js | 11 + src/operator-dashboard/static/lib/format.js | 51 ++ src/operator-dashboard/static/main.jsx | 6 + .../static/pages/FundsPage.jsx | 612 ++++++++++++++++++ .../static/pages/StrategyPage.jsx | 120 ++++ .../static/state/dashboardReducer.js | 141 ++++ src/operator-dashboard/static/styles.css | 496 ++++++++++++++ 15 files changed, 1625 insertions(+) create mode 100644 src/operator-dashboard/static/components/AlertsGrid.jsx create mode 100644 src/operator-dashboard/static/components/EmptyState.jsx create mode 100644 src/operator-dashboard/static/components/MetricCard.jsx create mode 100644 src/operator-dashboard/static/components/NavRail.jsx create mode 100644 src/operator-dashboard/static/components/Pill.jsx create mode 100644 src/operator-dashboard/static/components/StatusBar.jsx create mode 100644 src/operator-dashboard/static/components/TableFrame.jsx create mode 100644 src/operator-dashboard/static/index.html create mode 100644 src/operator-dashboard/static/lib/api.js create mode 100644 src/operator-dashboard/static/lib/format.js create mode 100644 src/operator-dashboard/static/main.jsx create mode 100644 src/operator-dashboard/static/pages/FundsPage.jsx create mode 100644 src/operator-dashboard/static/pages/StrategyPage.jsx create mode 100644 src/operator-dashboard/static/state/dashboardReducer.js create mode 100644 src/operator-dashboard/static/styles.css diff --git a/src/operator-dashboard/static/components/AlertsGrid.jsx b/src/operator-dashboard/static/components/AlertsGrid.jsx new file mode 100644 index 0000000..6030673 --- /dev/null +++ b/src/operator-dashboard/static/components/AlertsGrid.jsx @@ -0,0 +1,27 @@ +import EmptyState from './EmptyState.jsx'; +import Pill from './Pill.jsx'; +import { formatTimestamp } from '../lib/format.js'; + +export default function AlertsGrid({ items, emptyMessage = 'No alerts are active.' }) { + if (!items?.length) { + return {emptyMessage}; + } + + return ( +
+ {items.map((item, index) => ( +
+
+ {item.alert_code} + +
+
+
{item.reason}
+
{`Scope ${item.service_scope}`}
+
{formatTimestamp(item.raised_at || item.cleared_at || item.last_evaluated_at)}
+
+
+ ))} +
+ ); +} diff --git a/src/operator-dashboard/static/components/EmptyState.jsx b/src/operator-dashboard/static/components/EmptyState.jsx new file mode 100644 index 0000000..c272197 --- /dev/null +++ b/src/operator-dashboard/static/components/EmptyState.jsx @@ -0,0 +1,3 @@ +export default function EmptyState({ children }) { + return
{children}
; +} diff --git a/src/operator-dashboard/static/components/MetricCard.jsx b/src/operator-dashboard/static/components/MetricCard.jsx new file mode 100644 index 0000000..0ad2136 --- /dev/null +++ b/src/operator-dashboard/static/components/MetricCard.jsx @@ -0,0 +1,25 @@ +import { signedClass } from '../lib/format.js'; + +export default function MetricCard({ + label, + value, + meta = '', + signedValue = null, + hero = false, + valueClassName = '', + valueTitle = '', +}) { + const classes = ['metric-card', hero ? 'hero' : ''].filter(Boolean).join(' '); + return ( +
+
{label}
+
+ {value || 'Unavailable'} +
+
{meta || ''}
+
+ ); +} diff --git a/src/operator-dashboard/static/components/NavRail.jsx b/src/operator-dashboard/static/components/NavRail.jsx new file mode 100644 index 0000000..ca18a92 --- /dev/null +++ b/src/operator-dashboard/static/components/NavRail.jsx @@ -0,0 +1,38 @@ +const NAV_ITEMS = [ + { + page: 'funds', + title: 'Funds', + description: 'Profitability, balances, funding, quotes, and trades.', + }, + { + page: 'strategy', + title: 'Strategy', + description: 'Trading state, decision flow, and guarded omissions.', + }, + { + page: 'system', + title: 'System', + description: 'Service health, alerts, persistence, and safe controls.', + }, +]; + +export default function NavRail({ activePage, onPageChange }) { + return ( + + ); +} diff --git a/src/operator-dashboard/static/components/Pill.jsx b/src/operator-dashboard/static/components/Pill.jsx new file mode 100644 index 0000000..52aacf0 --- /dev/null +++ b/src/operator-dashboard/static/components/Pill.jsx @@ -0,0 +1,23 @@ +export default function Pill({ label, stateLabel = label }) { + const normalized = String(stateLabel || '').toLowerCase(); + let className = 'pill'; + + if ( + normalized.includes('critical') + || normalized.includes('failed') + || normalized.includes('offline') + || normalized.includes('frozen') + ) { + className += ' severity-critical'; + } else if ( + normalized.includes('warning') + || normalized.includes('degraded') + || normalized.includes('paused') + ) { + className += ' severity-warning'; + } else { + className += ' severity-info'; + } + + return {label || ''}; +} diff --git a/src/operator-dashboard/static/components/StatusBar.jsx b/src/operator-dashboard/static/components/StatusBar.jsx new file mode 100644 index 0000000..787ca45 --- /dev/null +++ b/src/operator-dashboard/static/components/StatusBar.jsx @@ -0,0 +1,52 @@ +import { formatAge, formatBoolean, formatEur, formatTimestamp, signedClass, truncateMiddle } from '../lib/format.js'; + +function statusSubtitle(label, status, websocketState) { + switch (label) { + case 'Pair': + return `WebSocket ${websocketState}`; + case 'Market Freshness': + return formatTimestamp(status.market_observed_at); + case 'Inventory Freshness': + return formatTimestamp(status.inventory_observed_at); + case 'Trading': + return 'Successful submissions from durable history'; + default: + return ''; + } +} + +export default function StatusBar({ status, websocketState }) { + const tiles = [ + ['Pair', truncateMiddle(status.active_pair, 40), status.active_pair], + ['Portfolio', formatEur(status.current_total_portfolio_value_eure)], + ['Reference BTC/EUR', formatEur(status.latest_reference_price_eure_per_btc)], + ['Market Freshness', formatAge(status.market_freshness_ms)], + ['Inventory Freshness', formatAge(status.inventory_freshness_ms)], + ['Alerts', `${status.active_alert_count || 0} ${status.highest_alert_severity ? `(${status.highest_alert_severity})` : ''}`.trim()], + ['Strategy Armed', formatBoolean(status.strategy_armed)], + ['Executor Armed', formatBoolean(status.executor_armed)], + ['Trading', `${status.recent_trade_count || 0} trades`], + ['Last Trade', formatTimestamp(status.last_successful_trade_at)], + ]; + + return ( +
+ {tiles.map(([label, value, fullValue = value]) => ( +
+
{label}
+
+ {value || 'Unavailable'} +
+
{statusSubtitle(label, status, websocketState)}
+
+ ))} +
+ ); +} diff --git a/src/operator-dashboard/static/components/TableFrame.jsx b/src/operator-dashboard/static/components/TableFrame.jsx new file mode 100644 index 0000000..3309114 --- /dev/null +++ b/src/operator-dashboard/static/components/TableFrame.jsx @@ -0,0 +1,8 @@ +export default function TableFrame({ children, className = '', style = undefined }) { + const classes = ['table-wrap', className].filter(Boolean).join(' '); + return ( +
+ {children} +
+ ); +} diff --git a/src/operator-dashboard/static/index.html b/src/operator-dashboard/static/index.html new file mode 100644 index 0000000..7369717 --- /dev/null +++ b/src/operator-dashboard/static/index.html @@ -0,0 +1,12 @@ + + + + + + unrip operator dashboard + + +
+ + + diff --git a/src/operator-dashboard/static/lib/api.js b/src/operator-dashboard/static/lib/api.js new file mode 100644 index 0000000..2b59092 --- /dev/null +++ b/src/operator-dashboard/static/lib/api.js @@ -0,0 +1,11 @@ +export async function fetchJson(url, options = {}) { + const response = await fetch(url, options); + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + + if (!response.ok) { + throw new Error(data?.error || `HTTP ${response.status}`); + } + + return data; +} diff --git a/src/operator-dashboard/static/lib/format.js b/src/operator-dashboard/static/lib/format.js new file mode 100644 index 0000000..c88642e --- /dev/null +++ b/src/operator-dashboard/static/lib/format.js @@ -0,0 +1,51 @@ +export function formatBoolean(value) { + if (value == null) return 'Unknown'; + return value ? 'Yes' : 'No'; +} + +export function formatTimestamp(value) { + if (!value) return 'Unavailable'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return 'Unavailable'; + return date.toLocaleString(); +} + +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`; +} + +export function formatEur(value) { + if (value == null || value === '') return 'Unavailable'; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return String(value); + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'EUR', + maximumFractionDigits: 2, + }).format(numeric); +} + +export function signedClass(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return ''; + if (numeric > 0) return 'value-positive'; + if (numeric < 0) return 'value-negative'; + return ''; +} + +export function truncateMiddle(value, maxLength = 40) { + const text = String(value || ''); + if (!text || text.length <= maxLength) return text; + const visible = Math.max(8, maxLength - 1); + const startLength = Math.ceil(visible / 2); + const endLength = Math.floor(visible / 2); + return `${text.slice(0, startLength)}…${text.slice(-endLength)}`; +} + +export function stringifyJson(value) { + return JSON.stringify(value, null, 2); +} diff --git a/src/operator-dashboard/static/main.jsx b/src/operator-dashboard/static/main.jsx new file mode 100644 index 0000000..1a509cd --- /dev/null +++ b/src/operator-dashboard/static/main.jsx @@ -0,0 +1,6 @@ +import { createRoot } from 'react-dom/client'; + +import App from './App.jsx'; +import './styles.css'; + +createRoot(document.getElementById('app')).render(); diff --git a/src/operator-dashboard/static/pages/FundsPage.jsx b/src/operator-dashboard/static/pages/FundsPage.jsx new file mode 100644 index 0000000..88c204a --- /dev/null +++ b/src/operator-dashboard/static/pages/FundsPage.jsx @@ -0,0 +1,612 @@ +import { 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 { formatEur, formatTimestamp, stringifyJson, truncateMiddle } from '../lib/format.js'; + +function buildInitialEstimateForm(balances, withdrawalDefaults) { + const firstAssetId = balances?.[0]?.asset_id || ''; + return { + asset_id: firstAssetId, + amount: '', + destination_address: + withdrawalDefaults?.[firstAssetId] + || Object.values(withdrawalDefaults || {})[0] + || '', + chain: '', + }; +} + +function BalancesTable({ items }) { + if (!items?.length) return No inventory snapshot is available yet.; + + return ( + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
AssetSpendablePending inboundPending outboundEUR value
+ {item.symbol} +
{item.asset_id}
+
{item.spendable}{item.pending_inbound}{item.pending_outbound}{formatEur(item.eur_value_eure)}
+
+ ); +} + +function HandlesList({ handles }) { + if (!handles?.length) return No funding handles are available.; + + return ( +
+ {handles.map((handle) => ( +
+
+ {handle.symbol} + +
+
+
{handle.address || 'Unavailable'}
+ {handle.memo ?
{`Memo ${handle.memo}`}
: null} +
{`Refreshed ${formatTimestamp(handle.refreshed_at)}`}
+
+
+ ))} +
+ ); +} + +function FundingTable({ items, creditedOnly = false }) { + if (!items?.length) { + return ( + + {creditedOnly ? 'No credited deposits have been recorded yet.' : 'No funding observations are available.'} + + ); + } + + return ( + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
StatusAssetAmountHandleObserved
{item.asset_symbol || item.asset_id}{item.amount_display || item.amount || 'Unavailable'}{truncateMiddle(item.funding_handle || '', 36)}{formatTimestamp(item.credited_at || item.last_seen_at || item.first_seen_at)}
+
+ ); +} + +function PreCreditAssetTable({ items }) { + if (!items?.length) return No pre-credit funding is currently tracked.; + + return ( + + + + + + + + + + + + {items.map((item) => ( + + + + + + + ))} + +
AssetPending amountObservation countLast seen
{item.asset_symbol || item.asset_id}{item.amount_display || item.amount || 'Unavailable'}{item.observation_count || 0}{formatTimestamp(item.last_seen_at)}
+
+ ); +} + +function WithdrawalsTable({ items }) { + if (!items?.length) return No withdrawals are tracked yet.; + + return ( + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
StatusAssetAmountDestinationObserved
{item.asset_symbol || item.asset_id}{item.amount_display || item.amount || 'Unavailable'}{truncateMiddle(item.destination_address || '', 36)}{formatTimestamp(item.completed_at || item.requested_at)}
+
+ ); +} + +function QuotesTable({ items }) { + if (!items?.length) return No quotes have been captured yet.; + + return ( + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
ObservedPairKindAmount inAmount out
{formatTimestamp(item.observed_at || item.ingested_at)}{truncateMiddle(item.pair || '', 38)}{item.request_kind || ''}{item.amount_in || ''}{item.amount_out || ''}
+
+ ); +} + +function AssetChangeTable({ items }) { + if (!items?.length) return No successful trades are available yet.; + + return ( + + + + + + + + + + + + {items.map((item) => ( + + + + + + + ))} + +
ObservedQuoteSpendReceive
{formatTimestamp(item.observed_at)}{item.quote_id || ''}{`${item.amount_in} ${item.asset_in_symbol}`}{`${item.amount_out} ${item.asset_out_symbol}`}
+
+ ); +} + +function TradesTable({ items }) { + if (!items?.length) return No successful trades are stored yet.; + + return ( + + + + + + + + + + + + + + {items.map((trade) => ( + + + + + + + + + ))} + +
ObservedPairKindSubmittedEdgeResult
{formatTimestamp(trade.observed_at)}{truncateMiddle(trade.pair || '', 38)}{trade.request_kind || ''}{`${trade.amount_in_display || ''} ${trade.asset_in_symbol || ''} -> ${trade.amount_out_display || ''} ${trade.asset_out_symbol || ''}`} 0 ? 'value-positive' : Number(trade.gross_edge_pct) < 0 ? 'value-negative' : ''}>{trade.gross_edge_pct || ''}
+
+ ); +} + +function WithdrawalEstimateForm({ balances, withdrawalDefaults, onControl }) { + const [form, setForm] = useState(() => buildInitialEstimateForm(balances, withdrawalDefaults)); + + useEffect(() => { + setForm(buildInitialEstimateForm(balances, withdrawalDefaults)); + }, [balances, withdrawalDefaults]); + + async function handleSubmit(event) { + event.preventDefault(); + const body = { ...form }; + if (!body.chain) delete body.chain; + await onControl('liquidity-manager', 'withdrawal-estimate', body, { reload: false }); + } + + return ( +
+
+
+ + +
+
+ + setForm((current) => ({ ...current, amount: event.target.value }))} + placeholder="1000000" + required + value={form.amount} + /> +
+
+ + setForm((current) => ({ ...current, destination_address: event.target.value }))} + value={form.destination_address} + /> +
+
+ + setForm((current) => ({ ...current, chain: event.target.value }))} + placeholder="Optional" + value={form.chain} + /> +
+
+
+ +
+
+ ); +} + +function LastControlResult({ result }) { + if (!result) return null; + + return ( + + + + + + + + +
Last control result
{stringifyJson(result)}
+
+ ); +} + +export default function FundsPage({ + funds, + onTradesPageChange, + onControl, + lastControlResult, +}) { + const profitability = funds.profitability; + const trades = funds.successful_trades; + const controlState = funds.funding.control_state || {}; + const externalFlowAdjusted = profitability.external_flow_adjusted; + const externalFlowCount = profitability.external_flow_count || 0; + const baselineLabel = externalFlowAdjusted ? 'PnL vs net funded capital' : 'PnL vs deposit baseline'; + const baselineMeta = externalFlowAdjusted + ? `Adjusted for ${externalFlowCount} later deposit or withdrawal flows` + : 'Baseline anchored before first live trade'; + const simpleHoldMeta = externalFlowAdjusted + ? 'Simple hold includes later credited deposits and completed withdrawals' + : 'Trading contribution over simple hold'; + const marketMoveMeta = externalFlowAdjusted + ? 'Simple-hold market move on baseline plus later external flows' + : 'Baseline mark move only'; + const tradingContributionMeta = externalFlowAdjusted + ? 'Current minus cash-flow-adjusted simple hold' + : 'Current minus simple hold'; + + return ( + <> +
+
+
+
Default view
+

Funds

+
+ Profitability comes first. Spendable inventory remains verifier or bridge credit only, + while pre-credit funding stays separate. +
+
+
+ + +
+
+
+ + + + + + +
+
+ {profitability.caveats.map((item) => ( +
{item}
+ ))} +
+
+ +
+
+
+
+
Current balances
+

Spendable and pending

+
+
{`Inventory synced ${formatTimestamp(funds.balances.synced_at)}`}
+
+ +
+
+
+
+
Operator controls
+

Safe actions

+
+
+
+ + + + + + + +
+
+ Risky treasury submit paths remain absent. Strategy and executor arm or disarm remain absent. +
+ + +
+
+ +
+
+
+
+
Funding handles
+

Credited and pre-credit funding

+
+
+ +

Credited deposits

+ +

Pre-credit by asset

+ +
+ +
+
+
+
Transfer history
+

Withdrawals and observations

+
+
+

Recent withdrawals

+ +

Recent funding observations

+ +
+
+ +
+
+
+
+
Live feed
+

Recent incoming quotes

+
+
WebSocket live feed, capped at 10
+
+ +
+
+
+
+
Trade-driven changes
+

Recent asset deltas

+
+
+ +
+
+ +
+
+
+
Durable ledger
+

Successful trades

+
+
PostgreSQL-backed pagination
+
+ +
+
{`Page ${trades.page} of ${trades.total_pages} - ${trades.total} successful trades`}
+
+ + +
+
+
+ + ); +} diff --git a/src/operator-dashboard/static/pages/StrategyPage.jsx b/src/operator-dashboard/static/pages/StrategyPage.jsx new file mode 100644 index 0000000..e241ba1 --- /dev/null +++ b/src/operator-dashboard/static/pages/StrategyPage.jsx @@ -0,0 +1,120 @@ +import AlertsGrid from '../components/AlertsGrid.jsx'; +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, formatTimestamp, truncateMiddle } from '../lib/format.js'; + +function describeStrategyDecision(decision) { + if (decision === 'actionable') { + return { + label: 'Actionable', + stateLabel: 'healthy', + }; + } + + if (decision === 'rejected') { + return { + label: 'Rejected by strategy', + stateLabel: 'warning', + }; + } + + return { + label: decision || 'Unknown', + stateLabel: decision || 'unknown', + }; +} + +function DecisionsTable({ items }) { + if (!items?.length) return No strategy decisions have been observed yet.; + + return ( + + + + + + + + + + + + + + {items.map((item, index) => { + const verdict = describeStrategyDecision(item.decision); + return ( + + + + + + + + + ); + })} + +
AtStrategy verdictPairReasonEdge %Notional
{formatTimestamp(item.decision_at)}{truncateMiddle(item.pair || '', 32)}{item.decision_reason || ''} 0 ? 'value-positive' : Number(item.gross_edge_pct) < 0 ? 'value-negative' : ''}>{item.gross_edge_pct || ''}{item.eure_notional || ''}
+
+ ); +} + +export default function StrategyPage({ strategy }) { + return ( + <> +
+
+
+
Trading state
+

Strategy and executor

+
+ This page shows whether the system is actively deciding and submitting without offering risky arming controls. +
+
+
+
+ + + + + + +
+
+ +
+
+
+
+
Decision flow
+

Recent decisions and skip reasons

+
+ Rejected by strategy means the strategy engine did not emit a trade command. Venue or executor rejections appear elsewhere. +
+
+
+ +
+ +
+
+
+
Guard rails
+

Omitted risky controls

+
+
+ + {strategy.omitted_controls.map((item) => ( +
{item}
+ ))} +
+

Relevant alerts

+ +
+
+ + ); +} diff --git a/src/operator-dashboard/static/state/dashboardReducer.js b/src/operator-dashboard/static/state/dashboardReducer.js new file mode 100644 index 0000000..327fe3f --- /dev/null +++ b/src/operator-dashboard/static/state/dashboardReducer.js @@ -0,0 +1,141 @@ +function applySocketMessage(dashboard, payload, session) { + if (!dashboard) return { dashboard, session }; + + switch (payload.type) { + case 'session.ready': + return { + session: payload.session || session, + dashboard: { + ...dashboard, + funds: { + ...dashboard.funds, + recent_quotes: payload.live?.recent_quotes || dashboard.funds.recent_quotes, + }, + status_bar: { + ...dashboard.status_bar, + ...(payload.live?.status_bar || {}), + }, + }, + }; + case 'quotes.recent': + return { + session, + dashboard: { + ...dashboard, + funds: { + ...dashboard.funds, + recent_quotes: payload.recent_quotes, + }, + }, + }; + case 'status_bar.updated': + return { + session, + dashboard: { + ...dashboard, + status_bar: { + ...dashboard.status_bar, + ...payload.status_bar, + }, + funds: { + ...dashboard.funds, + profitability: { + ...dashboard.funds.profitability, + recent_trade_count: + payload.status_bar.recent_trade_count + ?? dashboard.funds.profitability.recent_trade_count, + last_successful_trade_at: + payload.status_bar.last_successful_trade_at + || dashboard.funds.profitability.last_successful_trade_at, + }, + }, + }, + }; + case 'alerts.updated': + return { + session, + dashboard: { + ...dashboard, + status_bar: { + ...dashboard.status_bar, + active_alert_count: payload.alerts.active_alert_count, + highest_alert_severity: payload.alerts.highest_alert_severity, + }, + }, + }; + default: + return { dashboard, session }; + } +} + +export const initialDashboardState = { + session: null, + dashboard: null, + page: null, + notice: null, + error: null, + websocketState: 'connecting', + lastControlResult: null, +}; + +export function dashboardReducer(state, action) { + switch (action.type) { + case 'session.loaded': + return { + ...state, + session: action.session, + }; + case 'bootstrap.loaded': + return { + ...state, + dashboard: action.dashboard, + page: state.page || action.dashboard.default_page || 'funds', + }; + case 'trades.loaded': + return { + ...state, + dashboard: { + ...state.dashboard, + funds: { + ...state.dashboard.funds, + successful_trades: action.successfulTrades, + }, + }, + }; + case 'page.changed': + return { + ...state, + page: action.page, + }; + case 'notice.changed': + return { + ...state, + notice: action.notice, + }; + case 'error.changed': + return { + ...state, + error: action.error, + }; + case 'control.result': + return { + ...state, + lastControlResult: action.result, + }; + case 'websocket.state.changed': + return { + ...state, + websocketState: action.websocketState, + }; + case 'socket.message.received': { + const next = applySocketMessage(state.dashboard, action.payload, state.session); + return { + ...state, + session: next.session, + dashboard: next.dashboard, + }; + } + default: + return state; + } +} diff --git a/src/operator-dashboard/static/styles.css b/src/operator-dashboard/static/styles.css new file mode 100644 index 0000000..cb7c985 --- /dev/null +++ b/src/operator-dashboard/static/styles.css @@ -0,0 +1,496 @@ +:root { + --bg: #0f1714; + --bg-2: #16211d; + --panel: rgba(244, 240, 232, 0.93); + --panel-2: rgba(255, 252, 246, 0.98); + --ink: #18211e; + --muted: #60716a; + --line: rgba(24, 33, 30, 0.14); + --accent: #1f7a5a; + --accent-2: #0f5b84; + --warn: #b36a0c; + --critical: #b64328; + --ok: #1f7a5a; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.28); + --radius: 20px; + --radius-sm: 14px; + --mono: "IBM Plex Mono", "SFMono-Regular", "Menlo", monospace; + --sans: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + font-family: var(--sans); + background: + radial-gradient(circle at top left, rgba(31, 122, 90, 0.28), transparent 32%), + radial-gradient(circle at top right, rgba(15, 91, 132, 0.22), transparent 28%), + linear-gradient(180deg, #09100e 0%, #101815 55%, #0a100f 100%); + color: #f7f4ec; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px); + background-size: 32px 32px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.78), transparent 100%); +} + +a { + color: inherit; +} + +button, +input, +select { + font: inherit; +} + +.shell { + width: min(1440px, calc(100vw - 28px)); + margin: 0 auto; + padding: 18px 0 36px; +} + +.status-bar { + position: sticky; + top: 14px; + z-index: 20; + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + padding: 18px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 24px; + background: rgba(8, 12, 11, 0.84); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.26); + backdrop-filter: blur(14px); +} + +.status-tile { + min-width: 0; + min-height: 92px; + padding: 12px 14px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.status-label, +.eyebrow { + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: rgba(247, 244, 236, 0.72); +} + +.status-value, +.metric-value { + margin-top: 10px; + font-size: 1.22rem; + font-weight: 700; +} + +.status-subtle, +.muted { + margin-top: 6px; + color: rgba(247, 244, 236, 0.66); + font-size: 0.88rem; +} + +.app-grid { + margin-top: 20px; + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 20px; +} + +.side-nav { + padding: 16px; + border-radius: var(--radius); + background: rgba(8, 12, 11, 0.78); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: var(--shadow); + height: fit-content; +} + +.nav-title { + margin: 0 0 14px; + font-size: 1rem; + font-weight: 700; +} + +.nav-stack { + display: grid; + gap: 10px; +} + +.nav-button, +.button { + border: 0; + border-radius: 14px; + padding: 12px 14px; + text-align: left; + color: inherit; + background: rgba(255, 255, 255, 0.06); + cursor: pointer; + transition: transform 160ms ease, background 160ms ease; +} + +.nav-button:hover, +.button:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.1); +} + +.nav-button.active { + background: linear-gradient(135deg, rgba(31, 122, 90, 0.88), rgba(15, 91, 132, 0.88)); + box-shadow: 0 16px 34px rgba(0, 0, 0, 0.24); +} + +.content { + display: grid; + gap: 18px; +} + +.banner { + padding: 14px 18px; + border-radius: 18px; + background: rgba(8, 12, 11, 0.78); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.banner + .banner { + margin-top: 12px; +} + +.banner.ok { + border-color: rgba(31, 122, 90, 0.48); +} + +.banner.error { + border-color: rgba(182, 67, 40, 0.58); +} + +.panel { + padding: 20px; + border-radius: var(--radius); + background: var(--panel); + color: var(--ink); + box-shadow: var(--shadow); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.panel h2, +.panel h3 { + margin: 0; +} + +.panel-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; +} + +.panel-subtitle { + margin-top: 8px; + color: var(--muted); + max-width: 72ch; +} + +.metric-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.metric-card { + min-width: 0; + padding: 16px; + border-radius: 18px; + background: var(--panel-2); + border: 1px solid var(--line); +} + +.metric-card.hero { + background: linear-gradient(135deg, rgba(31, 122, 90, 0.16), rgba(15, 91, 132, 0.12)); +} + +.metric-meta { + margin-top: 8px; + color: var(--muted); + font-size: 0.9rem; +} + +.value-positive { + color: var(--ok); +} + +.value-negative { + color: var(--critical); +} + +.section-grid { + display: grid; + gap: 18px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.stack-grid { + display: grid; + gap: 18px; + grid-template-columns: 1fr; +} + +.table-wrap { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.52); +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 620px; +} + +table.pair-compact-table, +table.decision-table { + min-width: 0; +} + +table.pair-compact-table, +table.decision-table { + table-layout: fixed; +} + +table.pair-compact-table td:nth-child(2), +table.pair-compact-table th:nth-child(2) { + width: 24%; +} + +th, +td { + padding: 12px 14px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + +th { + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); + background: rgba(255, 255, 255, 0.72); +} + +tr:last-child td { + border-bottom: 0; +} + +.mono { + font-family: var(--mono); +} + +.truncate-line, +.truncate-cell { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font: inherit; +} + +table.decision-table td:nth-child(2), +table.decision-table th:nth-child(2) { + width: 34%; +} + +table.decision-table td:nth-child(3), +table.decision-table th:nth-child(3) { + width: 36%; +} + +.pills, +.button-row, +.form-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 11px; + border-radius: 999px; + background: rgba(24, 33, 30, 0.08); + color: var(--ink); + font-size: 0.82rem; +} + +.pill.severity-critical, +.pill.bad { + background: rgba(182, 67, 40, 0.12); + color: var(--critical); +} + +.pill.severity-warning, +.pill.warn { + background: rgba(179, 106, 12, 0.12); + color: var(--warn); +} + +.pill.severity-info, +.pill.good { + background: rgba(31, 122, 90, 0.12); + color: var(--ok); +} + +.button { + background: rgba(24, 33, 30, 0.92); + color: #f7f4ec; +} + +.button.secondary { + background: rgba(24, 33, 30, 0.08); + color: var(--ink); + border: 1px solid var(--line); +} + +.button[disabled] { + opacity: 0.45; + cursor: default; + transform: none; +} + +.button-row { + margin-top: 12px; +} + +.form-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + margin-top: 14px; +} + +.field { + display: grid; + gap: 6px; +} + +.field label { + font-size: 0.84rem; + color: var(--muted); +} + +.field input, +.field select { + width: 100%; + padding: 11px 12px; + border-radius: 12px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.82); + color: var(--ink); +} + +.split { + display: grid; + gap: 18px; + grid-template-columns: 1.3fr 0.9fr; +} + +.service-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.service-card { + padding: 16px; + border-radius: 18px; + background: var(--panel-2); + border: 1px solid var(--line); +} + +.service-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.service-detail { + margin-top: 12px; + display: grid; + gap: 8px; + color: var(--muted); +} + +.pagination { + margin-top: 12px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.empty { + padding: 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.56); + color: var(--muted); +} + +@media (max-width: 1100px) { + .app-grid, + .split { + grid-template-columns: 1fr; + } + + .side-nav { + position: static; + } +} + +@media (max-width: 720px) { + .shell { + width: min(100vw - 16px, 100%); + padding-top: 10px; + } + + .status-bar { + top: 8px; + border-radius: 20px; + } + + .panel, + .side-nav { + padding: 16px; + } + + table { + min-width: 540px; + } +}