chore(snapshot): sync frontend workspace state
This commit is contained in:
290
apps/visualizer/src/features/bot/useBotObserver.ts
Normal file
290
apps/visualizer/src/features/bot/useBotObserver.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
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<string, unknown> | 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<T>(pathname: string): Promise<T> {
|
||||
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<BotConfigSummary | null>(null);
|
||||
const [resolvedBy, setResolvedBy] = useState<string | null>(null);
|
||||
const [state, setState] = useState<BotStateRow | null>(null);
|
||||
const [events, setEvents] = useState<BotEventRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user