import { useEffect, useRef, useState } from 'react'; import { useInterval } from '../../app/hooks/useInterval'; export type BotConfigSummary = { id: string; name: string; market_name: string; market_type: string; mode: string; kill_switch: boolean; updated_at: string; }; export type BotObserverRuntimeState = { service?: string; version?: string; appVersion?: string; observe_only?: boolean; market_name?: string; market_type?: string; mode?: string; kill_switch?: boolean; status?: string; strategy_type?: string; loop_ms?: number; last_snapshot?: { query_latency_ms?: number | null; data_age_ms?: number | null; candles_count?: number | null; stats_age_ms?: number | null; depth_age_ms?: number | null; buy_slippage_age_ms?: number | null; sell_slippage_age_ms?: number | null; } | null; last_features?: { mark_price?: number | null; oracle_price?: number | null; mid_price?: number | null; spread_bps?: number | null; stats_imbalance?: number | null; depth_bid_usd?: number | null; depth_ask_usd?: number | null; depth_imbalance?: number | null; buy_slippage_bps?: number | null; sell_slippage_bps?: number | null; mark_vs_oracle_bps?: number | null; mom_3s?: number | null; mom_10s?: number | null; mom_30s?: number | null; vol_30s?: number | null; } | null; last_gates?: { fresh?: boolean; spread_ok?: boolean; slippage_ok?: boolean; depth_ok?: boolean; has_candles?: boolean; all?: boolean; } | null; last_decision?: { side?: string; confidence?: number | null; long_score?: number | null; short_score?: number | null; target_notional_usd?: number | null; horizon_s?: number | null; skip_reason?: string | null; } | null; counters?: { loops?: number; decisions?: number; skips?: number; errors?: number; } | null; inactive_reason?: string; error?: string; trade_requested_but_observer_only?: boolean; }; export type BotStateRow = { bot_id: string; last_heartbeat_at: string | null; last_action_at: string | null; last_error: string | null; state: BotObserverRuntimeState | null; updated_at: string; }; export type BotEventRow = { id: number; ts: string; type: string; payload?: Record | null; }; export type BotObserverData = { bot: BotConfigSummary | null; resolvedBy: string | null; state: BotStateRow | null; events: BotEventRow[]; loading: boolean; error: string | null; warning: string | null; lastUpdatedAt: string | null; }; function envString(name: string): string | undefined { const value = (import.meta as any).env?.[name]; if (value == null) return undefined; const text = String(value).trim(); return text || undefined; } function getApiBase(): string { return envString('VITE_API_URL') || '/api'; } function buildApiUrl(pathname: string): string { const base = new URL(getApiBase(), window.location.origin); const basePath = base.pathname && base.pathname !== '/' ? base.pathname.replace(/\/$/, '') : ''; const [pathOnly, searchOnly = ''] = String(pathname || '').split('?'); base.pathname = `${basePath}${pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`}`; base.search = searchOnly ? `?${searchOnly}` : ''; base.hash = ''; return base.toString(); } async function apiRequest(pathname: string): Promise { const res = await fetch(buildApiUrl(pathname), { cache: 'no-store', credentials: 'same-origin', }); const text = await res.text(); let json: any = null; try { json = text ? JSON.parse(text) : null; } catch { throw new Error(`Invalid API JSON for ${pathname}`); } if (!res.ok) throw new Error(json?.error || `API HTTP ${res.status}`); return json as T; } function preferredBotId(): string | undefined { return envString('VITE_BOT_ID'); } function preferredBotName(): string | undefined { return envString('VITE_BOT_NAME'); } function resolveBot(bots: BotConfigSummary[], marketName: string): { bot: BotConfigSummary | null; by: string | null } { const configuredId = preferredBotId(); if (configuredId) { const match = bots.find((entry) => entry.id === configuredId); if (match) return { bot: match, by: 'env:id' }; } const configuredName = preferredBotName(); if (configuredName) { const match = bots.find((entry) => entry.name === configuredName); if (match) return { bot: match, by: 'env:name' }; } const match = bots.find((entry) => String(entry.market_name || '').trim().toUpperCase() === marketName.trim().toUpperCase()); if (match) return { bot: match, by: 'market' }; return { bot: null, by: null }; } export function useBotObserver(marketName: string): BotObserverData { const mountedRef = useRef(true); const resolveInFlightRef = useRef(false); const detailsInFlightRef = useRef(false); const [bot, setBot] = useState(null); const [resolvedBy, setResolvedBy] = useState(null); const [state, setState] = useState(null); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [warning, setWarning] = useState(null); const [lastUpdatedAt, setLastUpdatedAt] = useState(null); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); async function refreshBotResolution() { if (resolveInFlightRef.current) return; resolveInFlightRef.current = true; try { const json = await apiRequest<{ ok?: boolean; bots?: BotConfigSummary[]; error?: string; warning?: string }>( '/v1/bots?limit=100' ); const bots = Array.isArray(json?.bots) ? json.bots : []; const resolved = resolveBot(bots, marketName); if (!mountedRef.current) return; setBot((current) => { if (current?.id === resolved.bot?.id) return current; return resolved.bot; }); setResolvedBy(resolved.by); if (!resolved.bot) { setState(null); setEvents([]); setLoading(false); } setWarning(typeof json?.warning === 'string' && json.warning ? json.warning : null); setError(null); } catch (err) { if (!mountedRef.current) return; setError(String((err as Error)?.message || err)); setLoading(false); } finally { resolveInFlightRef.current = false; } } async function refreshBotDetails(botId: string) { if (detailsInFlightRef.current) return; detailsInFlightRef.current = true; try { const [stateJson, eventsJson] = await Promise.all([ apiRequest<{ ok?: boolean; state?: BotStateRow | null; error?: string; warning?: string }>( `/v1/bots/${botId}/state` ), apiRequest<{ ok?: boolean; events?: BotEventRow[]; error?: string; warning?: string }>( `/v1/bots/${botId}/events?limit=24` ), ]); if (!mountedRef.current) return; setState(stateJson?.state || null); setEvents(Array.isArray(eventsJson?.events) ? eventsJson.events : []); setLastUpdatedAt(new Date().toISOString()); setWarning( (typeof stateJson?.warning === 'string' && stateJson.warning) || (typeof eventsJson?.warning === 'string' && eventsJson.warning) || null ); setError(null); setLoading(false); } catch (err) { if (!mountedRef.current) return; setError(String((err as Error)?.message || err)); setLoading(false); } finally { detailsInFlightRef.current = false; } } useEffect(() => { setLoading(true); setBot(null); setResolvedBy(null); setState(null); setEvents([]); setError(null); setWarning(null); setLastUpdatedAt(null); void refreshBotResolution(); }, [marketName]); useEffect(() => { if (!bot?.id) return; void refreshBotDetails(bot.id); }, [bot?.id]); useInterval(() => { void refreshBotResolution(); }, 5000); useInterval(() => { if (!bot?.id) return; void refreshBotDetails(bot.id); }, bot?.id ? 1000 : null); return { bot, resolvedBy, state, events, loading, error, warning, lastUpdatedAt, }; }