// Dashboard — landing page. Stat cards, equity chart, ticker, trades, alerts. // // Mock-free as of M11.1 cleanup: every panel renders an empty / loading // state until the bot publishes real data. We never fall back to // hard-coded sample numbers — that masked which paths were live vs. // scaffolding and made it impossible for an operator to tell at a // glance. const { Card, Stat, Pill, Copy, fmtTs, fmtNum, fmtPct, since } = window.UI; const RC = window.Recharts; // adapt a backend apiTrade (alt_qty, crypto_qty, ts_ms) to the table shape // used throughout the UI (qty, price, fee, ts). function adaptTrade(t) { const alt = parseFloat(t.alt_qty || '0') || 0; const crypto = parseFloat(t.crypto_qty || '0') || 0; // Implied execution price = bridge spent / alt received. Real fee is // not on the API yet (M11.2 will add it via api_events); show 0 for // now so the column never reads a hardcoded mock value. const price = alt > 0 ? crypto / alt : 0; return { id: t.id, ts: t.ts_ms, from: t.from || '—', to: t.to || '—', qty: alt, price, fee: 0, state: t.state || 'PENDING', client_order_id: t.client_order_id || '', account: '', // filled by caller }; } function adaptAlert(a) { return { kind: a.kind || 'event', severity: a.severity || 'info', reason: a.message || a.code || '', at: a.at_ms || 0, account: '', }; } function adaptEquity(arr) { return (arr || []).map(p => ({ t: p.t_ms, v: parseFloat(p.v) || 0 })); } function PageDashboard({ app, setApp }) { const [window_, setWindow] = React.useState('24h'); // Resolve the active account from the live list. While accounts are // loading or the operator's chosen account vanished from the config, // render a placeholder so we never invent an account. const acct = (app.accounts || []).find(a => a.id === app.account) || null; const accountID = acct && acct.accountID; const { data: dash, error, loading, refresh } = window.API.useAPI( () => accountID ? window.API.get('/api/v1/dashboard', { account_id: accountID }) : Promise.resolve(null), [accountID], ); // Auto-refresh whenever a trade or equity event is broadcast on the // SSE bus so the cards land freshly-confirmed jumps within ~1s. // Depend on the events array identity (every push returns a fresh // ref) so the effect keeps re-running once the buffer caps out. const { events: dashEvents } = window.API.useSSE(['trade', 'equity'], 50); React.useEffect(() => { if (!dashEvents.length) return; refresh(); }, [dashEvents]); const equityData = adaptEquity(dash && dash.equity_curve); const haveEquity = equityData.length >= 2; const startV = haveEquity ? equityData[0].v : 0; const endV = haveEquity ? equityData[equityData.length - 1].v : 0; const pnl = endV - startV; const pnlPct = startV ? pnl / startV : 0; const recentTrades = (dash && Array.isArray(dash.recent_trades)) ? dash.recent_trades.map(t => ({ ...adaptTrade(t), account: acct ? acct.id : '' })) : []; const openAlerts = (dash && Array.isArray(dash.alerts)) ? dash.alerts.map(a => ({ ...adaptAlert(a), account: acct ? acct.id : '' })) : []; const currentCoin = (dash && dash.current_coin) || (acct && acct.current_coin) || ''; const outbox = (dash && dash.outbox) || null; // Symbol list for the ticker column comes from the configured account // (coin/ for each non-bridge coin). Empty until accounts load. const tickerSymbols = React.useMemo(() => { if (!acct || !Array.isArray(acct.coins) || !acct.bridge) return []; return acct.coins .filter(c => c !== acct.bridge) .map(c => `${c}/${acct.bridge}`); }, [acct && acct.id, acct && (acct.coins || []).join(','), acct && acct.bridge]); if (!app.accountsLoaded) { return (
loading accounts…
); } if (!acct) { return (
no account configured · set up accounts[] in your bot.yaml and restart the bot.
); } const liveTag = haveEquity ? 'live' : (loading ? 'loading…' : (error ? 'api error' : 'no data yet')); return (
{openAlerts.length > 0 && (

{openAlerts.length} active risk event{openAlerts.length > 1 ? 's' : ''}

latest {since(Math.max(...openAlerts.map(a => a.at)))}
    {openAlerts.slice(0, 3).map((a, i) => (
  • {a.kind.replace('_',' ')} {a.reason} — {a.account}
  • ))}
)} {/* Stat cards row */}
= 0 ? 'pos' : 'neg'} sub={haveEquity ? `${window_} window` : 'no data yet'} /> = 0 ? '+' : ''}${fmtNum(pnl, 2)} ${acct.bridge}` : '—'} deltaTone={pnl >= 0 ? 'pos' : 'neg'} />
{/* Main row: equity chart big, ticker strip narrow */}
{['1h','24h','7d','30d','all'].map(w => ( ))}
} > {haveEquity ? : } title="no equity samples yet" hint={loading ? 'loading…' : 'snapshots accumulate once balances + prices flow (M11.2)'} />}
{/* Holdings — coin count growth, the bot's actual target */} {/* Trades + outbox status */}
{recentTrades.length ? : } title="no trades yet" hint="filled jumps will appear here once the dispatcher loop is live (M11.2)" />}
); } // HoldingsTable — per-coin first vs current balance + delta. Bot's // optimisation target is to grow `current_qty` over time; the equity // curve is a side-effect (USD value = qty × price). This card answers // the question: "Did my coin count actually grow?" function HoldingsTable({ accountID }) { const { data, error, loading, refresh } = window.API.useAPI( () => accountID ? window.API.get('/api/v1/holdings', { account_id: accountID }) : Promise.resolve(null), [accountID], ); // Refresh on every confirmed trade or equity tick so the numbers // stay current without a polling loop. Depend on the events array // identity (each push = fresh ref) — using events.length stalls // once the buffer caps out. const { events } = window.API.useSSE(['trade', 'equity'], 20); React.useEffect(() => { if (events.length) refresh(); }, [events]); if (loading && !data) { return
loading…
; } if (error) { return
api error: {String(error.message || error)}
; } const items = (data && Array.isArray(data.items)) ? data.items : []; if (items.length === 0) { return (
no balance snapshots yet — the equity loop writes one row per minute, first sample lands within 60s of boot.
); } // Sort: current coin first, then initial coin, then by USD value desc. const sorted = items.slice().sort((a, b) => { if (a.is_current !== b.is_current) return a.is_current ? -1 : 1; if (a.is_initial !== b.is_initial) return a.is_initial ? -1 : 1; return parseFloat(b.current_usd_val || '0') - parseFloat(a.current_usd_val || '0'); }); return (
{sorted.map((h, i) => { const dq = parseFloat(h.delta_qty || '0'); const dp = parseFloat(h.delta_pct || '0'); const tone = dq > 0 ? 'pos' : dq < 0 ? 'neg' : 'ink'; return ( ); })}
coin first qty current qty Δ qty Δ % usd value
{h.coin} {h.is_current && current} {h.is_initial && !h.is_current && initial}
{fmtQty(h.first_qty)} {fmtQty(h.current_qty)} {dq > 0 ? '+' : ''}{fmtQty(h.delta_qty)} {h.delta_pct ? (dp > 0 ? '+' : '') + h.delta_pct + '%' : '—'} {h.current_usd_val ? '$' + h.current_usd_val : '—'}
); } function fmtQty(s) { if (!s) return '0'; const v = parseFloat(s); if (!isFinite(v)) return s; if (Math.abs(v) >= 1000) return v.toFixed(2); if (Math.abs(v) >= 1) return v.toFixed(4); if (Math.abs(v) >= 0.01) return v.toFixed(6); return v.toFixed(8); } function EmptyPanel({ icon, title, hint }) { return (
{icon}
{title}
{hint &&
{hint}
}
); } function EquityChart({ data, startV, bridge }) { const min = Math.min(...data.map(d => d.v)); const max = Math.max(...data.map(d => d.v)); const pad = (max - min) * 0.1 || (max * 0.001) || 1; const trend = data[data.length-1].v >= startV ? '#5fcf95' : '#e07262'; return (
fmtTs(t,{short:true})} stroke="#3a424d" tick={{ fontSize: 10, fontFamily: 'JetBrains Mono', fill: '#5b6573' }} axisLine={{ stroke: '#1f2630' }} tickLine={false} minTickGap={50}/> fmtNum(v,0)} /> { if (!active || !payload?.length) return null; const p = payload[0].payload; const delta = p.v - startV; return (
{fmtTs(p.t)}
{fmtNum(p.v, 2)} {bridge}
= 0 ? 'text-pos-0' : 'text-neg-0'}>{fmtPct(delta/startV, 2)} · {delta >= 0 ? '+' : ''}{fmtNum(delta,2)}
); }} cursor={{ stroke: '#7aaed8', strokeDasharray: '2 2' }} />
); } // TickerColumn — one row per configured pair. Rows start with placeholder // dashes; every SSE 'ticker' event for a matching symbol fills the row // in. We deliberately do NOT seed prices from any local source: an // empty row is more honest than a stale number. function TickerColumn({ symbols }) { const { connected, events } = window.API.useSSE(['ticker'], 200); // rows: { symbol → { last, change, flash, ts } } const [rowMap, setRowMap] = React.useState({}); // Reset row map when the configured symbol set changes (e.g. after // accounts hydrate or the operator switches accounts). React.useEffect(() => { const blank = {}; for (const s of symbols) blank[s] = { last: null, change: null, flash: null, ts: 0 }; setRowMap(blank); }, [symbols.join(',')]); // Apply the latest SSE event onto the matching row. React.useEffect(() => { if (!events.length) return; const last = events[events.length - 1]; if (last.topic !== 'ticker' || !last.data) return; const sym = last.data.symbol || last.data.Symbol; if (!sym) return; setRowMap(prev => { if (!(sym in prev)) return prev; // ignore symbols outside our config const row = prev[sym]; const lastPx = parseFloat(last.data.last ?? last.data.price ?? 0) || row.last; const change = (last.data.change != null) ? parseFloat(last.data.change) : row.change; const flash = row.last == null ? 'up' : (lastPx >= row.last ? 'up' : 'dn'); return { ...prev, [sym]: { last: lastPx, change, flash, ts: Date.now() } }; }); // events array identity changes on every push (useSSE returns a // fresh slice), so depending on the array — not its length — // keeps this effect firing once the 200-event tail caps out. }, [events]); // Clear flash highlight after a short delay so it pulses rather than // sticks. We mutate the row map but only the flash field. React.useEffect(() => { const id = setInterval(() => { setRowMap(prev => { let dirty = false; const out = {}; for (const k of Object.keys(prev)) { if (prev[k].flash) { out[k] = { ...prev[k], flash: null }; dirty = true; } else { out[k] = prev[k]; } } return dirty ? out : prev; }); }, 800); return () => clearInterval(id); }, []); return (
live ticker {connected ? 'sse connected' : 'sse offline'}
{symbols.length === 0 && (
no pairs configured
)} {symbols.map(sym => { const r = rowMap[sym] || { last: null, change: null, flash: null }; const has = r.last != null; return (
{sym}
{has ? fmtNum(r.last, r.last < 1 ? 4 : r.last < 100 ? 3 : 2) : '—'}
= 0 ? 'text-pos-0' : 'text-neg-0'}`}>{r.change == null ? '—' : fmtPct(r.change, 2)}
); })}
); } // Compact trades table (also used non-compact on Trades page) function TradesTable({ trades, compact, onRowClick }) { return (
{!compact && } {trades.map((t, i) => ( onRowClick?.(t)} className={`border-b border-line-0 ${onRowClick ? 'cursor-pointer hover:bg-bg-2/60' : ''} ${i % 2 ? 'bg-bg-1' : 'bg-bg-1/50'}`}> {!compact && } ))}
time from→to qtypricefee state cid
{fmtTs(t.ts, { short: true })} {t.from} {t.to} {fmtNum(t.qty, 4)}{fmtNum(t.price, 2)}{fmtNum(t.fee, 4)} {t.state.replace('_',' ')}
); } function OutboxMini({ stats }) { // No-data state vs. data state. While stats is null/undefined the // dashboard endpoint is still loading or unreachable; show a faint // empty layout rather than a row of zeros that looks like real "all // CONFIRMED" data. const has = stats != null; const lanes = [ { state: 'PENDING', count: has ? stats.pending : null, tone: 'ink' }, { state: 'DISPATCHING', count: has ? stats.dispatching : null, tone: 'inf' }, { state: 'SENT', count: has ? stats.sent : null, tone: 'inf' }, { state: 'CONFIRMED', count: has ? stats.confirmed : null, tone: 'pos' }, { state: 'FAILED', count: has ? stats.failed : null, tone: 'amb' }, { state: 'DEAD', count: has ? stats.dead : null, tone: 'neg' }, ]; return (
{lanes.map(l => (
{l.state}
0 ? 'text-ink-0' : 'text-ink-4'}`}>{l.count == null ? '—' : l.count}
))}
); } window.PageDashboard = PageDashboard; window.TradesTable = TradesTable;