291 lines
8.2 KiB
TypeScript
291 lines
8.2 KiB
TypeScript
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,
|
|
};
|
|
}
|