// Top-level app — auth + routing + global state. const { Sidebar, StatusSpine, NotificationsPanel, LoginScreen } = window.Shell; // /api/v1/me handshake. The hook returns one of three states: // { checking: true } — splash visible // { authed: true, authEnabled: bool, user } — render the app // { authed: false, authEnabled: bool } — render LoginScreen async function meCheck() { try { const r = await fetch('/api/v1/me', { credentials: 'same-origin', cache: 'no-store' }); if (r.status === 200) return await r.json(); if (r.status === 401) { const j = await r.json().catch(() => ({})); return { authed: false, auth_enabled: j.auth_enabled !== false }; } return { authed: false, auth_enabled: true }; } catch (_) { return { authed: false, auth_enabled: true, offline: true }; } } async function loginRequest(user, password) { const r = await fetch('/api/v1/login', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user, password }), }); const body = await r.json().catch(() => ({})); if (!r.ok) { const err = new Error(body.error || `login failed (${r.status})`); err.status = r.status; throw err; } return body; } async function logoutRequest() { try { await fetch('/api/v1/logout', { method: 'POST', credentials: 'same-origin' }); } catch (_) { /* ignore */ } } function App() { const [boot, setBoot] = React.useState({ checking: true, authed: false, authEnabled: true }); const [user, setUser] = React.useState('operator'); const [route, setRoute] = React.useState('dashboard'); const [notifOpen, setNotifOpen] = React.useState(false); const [app, setApp] = React.useState({ user: 'operator', account: '', // hydrated from /api/v1/accounts accounts: [], // populated after the boot fetch modeOverride: null, halted: null, // null = running; string reason = halted tickAge: 1, scoutRate: 0, now: Date.now(), unreadAlerts: 0, // bumped from /api/v1/dashboard alerts in M11.2 accountsLoaded: false, }); // Boot: check session via /api/v1/me, then load accounts from API. React.useEffect(() => { let cancelled = false; (async () => { const me = await meCheck(); if (cancelled) return; if (me.authed) { setUser(me.user || 'operator'); setApp(s => ({ ...s, user: me.user || s.user })); setBoot({ checking: false, authed: true, authEnabled: !!me.auth_enabled }); } else if (me.offline && localStorage.getItem('pb-auth') === '1') { // Designer preview / file:// — keep the SPA usable with mock data. setBoot({ checking: false, authed: true, authEnabled: false }); } else { setBoot({ checking: false, authed: false, authEnabled: !!me.auth_enabled }); } })(); return () => { cancelled = true; }; }, []); // Once authed, pull the live account list. The bot's app.Wire upserts // one row per configured account on every boot, so this is the single // source of truth for "which accounts exist" — no mock fallback. // accountsLoaded gates the page render so we never paint a stale // selection while the request is in flight. React.useEffect(() => { if (!boot.authed) return; let cancelled = false; (async () => { try { const raw = await window.API.get('/api/v1/accounts'); if (cancelled) return; const accounts = (Array.isArray(raw) ? raw : []).map(a => ({ id: a.label, accountID: a.id, exchange: a.exchange, mode: a.mode, bridge: a.bridge, coins: Array.isArray(a.coins) ? a.coins : [], current_coin: a.current_coin || '', })); setApp(s => ({ ...s, accounts, account: accounts[0] ? accounts[0].id : '', accountsLoaded: true, })); } catch (_) { // Network blip / unauthorized — leave the list empty so the // page renders an explicit 'no account configured' state // instead of silently inventing one. setApp(s => ({ ...s, accountsLoaded: true })); } })(); return () => { cancelled = true; }; }, [boot.authed]); // Poll the kill-switch status so the StatusSpine and the Control page // both reflect the real state without each rendering a separate fetch. React.useEffect(() => { if (!boot.authed) return; let cancelled = false; const tick = async () => { try { const s = await window.API.get('/api/v1/control'); if (cancelled) return; setApp(prev => ({ ...prev, halted: s.halted ? (s.reason || 'halted') : null })); } catch (_) { /* keep last known state on transient error */ } }; tick(); const id = setInterval(tick, 5000); return () => { cancelled = true; clearInterval(id); }; }, [boot.authed]); // Wall-clock tick + tickAge derivation from the latest ticker SSE // event + scoutRate from the latest scout SSE event. // // Bug history: this used to depend on `events.length` so the effect // re-ran on every new event. But useSSE caps the buffer at `keep`, // so once length hit the cap it stopped changing — the effect went // stale and tickAge crept upward forever even though events were // still arriving. Fix: depend on the events ARRAY identity (every // setEvents returns a fresh array reference) so the effect always // sees the latest tail. const { events: tickerEvents } = window.API.useSSE(['ticker'], 4); const { events: scoutEvents } = window.API.useSSE(['scout'], 4); const lastTickRef = React.useRef(0); const scoutRateRef = React.useRef(0); React.useEffect(() => { if (tickerEvents.length === 0) return; lastTickRef.current = tickerEvents[tickerEvents.length - 1].at; }, [tickerEvents]); React.useEffect(() => { if (scoutEvents.length === 0) return; const last = scoutEvents[scoutEvents.length - 1]; scoutRateRef.current = Number(last?.data?.iter_per_sec) || 0; }, [scoutEvents]); // Wall-clock + status-spine update — reads from refs, no dependency // on the SSE arrays so the interval keeps a single stable closure. React.useEffect(() => { if (!boot.authed) return; const id = setInterval(() => { const now = Date.now(); // tickAge semantics: null = "never seen a ticker" (badge hidden), // any number ≥ 0 = "this many seconds since the last frame". // Ticker frames on mainnet often arrive sub-second, so 0 is a // valid display value ("just now"); we used to hide the badge // when ageS===0 which made it flash on/off as soon as data // started flowing. Now we render <1s. const tickAge = lastTickRef.current ? Math.max(0, Math.floor((now - lastTickRef.current) / 1000)) : null; setApp(s => ({ ...s, now, tickAge, scoutRate: scoutRateRef.current })); }, 1000); return () => clearInterval(id); }, [boot.authed]); // Allow child components to navigate by setting app._navTo. Used by // the Status Spine's halted pill (clicks → Control page) and the // Onboarding splash (clicks → Dashboard). React.useEffect(() => { if (app._navTo && app._navTo !== route) { setRoute(app._navTo); setApp(s => ({ ...s, _navTo: null, _navAt: null })); } }, [app._navTo, app._navAt]); // keyboard shortcuts: 1/D/P/T/C/O/L/R/H React.useEffect(() => { const map = { '1': 'strategy101', d: 'dashboard', p: 'pairs', t: 'trades', c: 'config', o: 'control', l: 'logs', r: 'replay', h: 'onboarding' }; const h = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; if (e.metaKey || e.ctrlKey || e.altKey) return; const r = map[e.key.toLowerCase()]; if (r) setRoute(r); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, []); const handleLogin = async (u, p) => { const body = await loginRequest(u, p); const username = body.user || u; setUser(username); setApp(s => ({ ...s, user: username })); setBoot({ checking: false, authed: true, authEnabled: true }); localStorage.setItem('pb-auth', '1'); return body; }; const handleLogout = async () => { await logoutRequest(); localStorage.removeItem('pb-auth'); setBoot({ checking: false, authed: false, authEnabled: true }); }; if (boot.checking) { return (