import { useCallback, useEffect, useMemo, useState } from 'react'; import axios from 'axios'; import { APP_ENDPOINTS } from '../../../config/env'; import { resolveApiErrorMessage } from '../../../shared/http/apiErrors'; import type { BotState } from '../../../types/bot'; import type { ChannelLabels, DashboardLabels } from '../localeTypes'; import type { BotResourceSnapshot, NanobotImage, WeixinLoginStatus } from '../types'; type PromptTone = 'info' | 'success' | 'warning' | 'error'; type BotControlState = 'starting' | 'stopping' | 'enabling' | 'disabling'; interface NotifyOptions { title?: string; tone?: PromptTone; durationMs?: number; } interface ConfirmOptions { title: string; message: string; tone?: PromptTone; confirmLabel?: string; cancelLabel?: string; } interface UseDashboardRuntimeControlOptions { bots: BotState[]; forcedBotId?: string; selectedBotId: string; selectedBot?: BotState; isZh: boolean; t: DashboardLabels; lc: ChannelLabels; mergeBot: (bot: BotState) => void; setBots: (bots: BotState[]) => void; updateBotStatus: (botId: string, dockerStatus: string) => void; notify: (message: string, options?: NotifyOptions) => void; confirm: (options: ConfirmOptions) => Promise; } export function useDashboardRuntimeControl({ bots, forcedBotId, selectedBotId, selectedBot, isZh, t, lc, mergeBot, setBots, updateBotStatus, notify, confirm, }: UseDashboardRuntimeControlOptions) { const CONTROL_MIN_VISIBLE_MS = 1200; const [showResourceModal, setShowResourceModal] = useState(false); const [resourceBotId, setResourceBotId] = useState(''); const [resourceSnapshot, setResourceSnapshot] = useState(null); const [resourceLoading, setResourceLoading] = useState(false); const [resourceError, setResourceError] = useState(''); const [operatingBotId, setOperatingBotId] = useState(null); const [controlStateByBot, setControlStateByBot] = useState>({}); const [isBatchOperating, setIsBatchOperating] = useState(false); const [availableImages, setAvailableImages] = useState([]); const [weixinLoginStatus, setWeixinLoginStatus] = useState(null); const resourceBot = useMemo( () => bots.find((bot) => bot.id === resourceBotId), [bots, resourceBotId], ); const loadImageOptions = useCallback(async () => { const [imagesRes] = await Promise.allSettled([ axios.get(`${APP_ENDPOINTS.apiBase}/images`), ]); if (imagesRes.status === 'fulfilled') { setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []); } else { setAvailableImages([]); } }, []); const refresh = useCallback(async () => { const forced = String(forcedBotId || '').trim(); if (forced) { const targetId = String(selectedBotId || forced).trim() || forced; const botRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}`); setBots(botRes.data ? [botRes.data] : []); await loadImageOptions(); return; } const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`); setBots(botsRes.data); await loadImageOptions(); }, [forcedBotId, loadImageOptions, selectedBotId, setBots]); const ensureSelectedBotDetail = useCallback(async () => { if (!selectedBotId) return selectedBot; try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`); mergeBot(res.data); return res.data; } catch { return selectedBot; } }, [mergeBot, selectedBot, selectedBotId]); const loadResourceSnapshot = useCallback(async (botId: string) => { if (!botId) return; setResourceLoading(true); setResourceError(''); try { const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${botId}/resources`); setResourceSnapshot(res.data); } catch (error: unknown) { const fallback = isZh ? '读取资源监控失败。' : 'Failed to load resource metrics.'; const msg = resolveApiErrorMessage(error, fallback); setResourceError(String(msg)); } finally { setResourceLoading(false); } }, [isZh]); const openResourceMonitor = useCallback((botId: string) => { setResourceBotId(botId); setShowResourceModal(true); void loadResourceSnapshot(botId); }, [loadResourceSnapshot]); const loadWeixinLoginStatus = useCallback(async (botId: string, silent?: boolean) => { void silent; if (!botId) { setWeixinLoginStatus(null); return; } try { const res = await axios.get<{ bot_id: string; logs?: string[] }>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/logs`, { params: { tail: 500 }, }); const lines = Array.isArray(res.data?.logs) ? res.data.logs : []; const loginLine = [...lines].reverse().find((line) => /Login URL:\s*\S+/i.test(String(line || ''))); const loginUrl = loginLine ? String(loginLine).replace(/^.*?Login URL:\s*/i, '').trim() : ''; setWeixinLoginStatus({ bot_id: botId, docker_status: '', status: loginUrl ? 'available' : 'missing', login_url: loginUrl, login_image_url: loginUrl, is_active: true, has_saved_state: false, raw_line: loginLine || null, }); } catch { setWeixinLoginStatus({ bot_id: botId, docker_status: '', status: 'missing', login_url: '', login_image_url: null, is_active: false, has_saved_state: false, raw_line: null, }); } }, []); const reloginWeixin = useCallback(async () => { if (!selectedBot) return; try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/weixin/relogin`); notify(lc.weixinReloginDone, { tone: 'success' }); await loadWeixinLoginStatus(selectedBot.id); await refresh(); } catch (error: unknown) { notify(resolveApiErrorMessage(error, lc.weixinReloginFail), { tone: 'error' }); } }, [lc.weixinReloginDone, lc.weixinReloginFail, loadWeixinLoginStatus, notify, refresh, selectedBot]); const batchStartBots = useCallback(async () => { if (isBatchOperating) return; const candidates = bots.filter((bot) => bot.enabled !== false && String(bot.docker_status || '').toUpperCase() !== 'RUNNING'); if (candidates.length === 0) { notify(t.batchStartNone, { tone: 'warning' }); return; } const ok = await confirm({ title: t.batchStart, message: t.batchStartConfirm(candidates.length), tone: 'warning', }); if (!ok) return; setIsBatchOperating(true); let success = 0; let failed = 0; try { for (const bot of candidates) { try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${bot.id}/start`); updateBotStatus(bot.id, 'RUNNING'); success += 1; } catch { failed += 1; } } await refresh(); notify(t.batchStartDone(success, failed), { tone: failed > 0 ? 'warning' : 'success' }); } finally { setIsBatchOperating(false); } }, [bots, confirm, isBatchOperating, notify, refresh, t, updateBotStatus]); const batchStopBots = useCallback(async () => { if (isBatchOperating) return; const candidates = bots.filter((bot) => bot.enabled !== false && String(bot.docker_status || '').toUpperCase() === 'RUNNING'); if (candidates.length === 0) { notify(t.batchStopNone, { tone: 'warning' }); return; } const ok = await confirm({ title: t.batchStop, message: t.batchStopConfirm(candidates.length), tone: 'warning', }); if (!ok) return; setIsBatchOperating(true); let success = 0; let failed = 0; try { for (const bot of candidates) { try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${bot.id}/stop`); updateBotStatus(bot.id, 'STOPPED'); success += 1; } catch { failed += 1; } } await refresh(); notify(t.batchStopDone(success, failed), { tone: failed > 0 ? 'warning' : 'success' }); } finally { setIsBatchOperating(false); } }, [bots, confirm, isBatchOperating, notify, refresh, t, updateBotStatus]); const ensureControlVisible = useCallback(async (startedAt: number) => { const elapsed = Date.now() - startedAt; const remain = CONTROL_MIN_VISIBLE_MS - elapsed; if (remain > 0) { await new Promise((resolve) => window.setTimeout(resolve, remain)); } }, []); const setControlState = useCallback((id: string, state: BotControlState) => { setControlStateByBot((prev) => ({ ...prev, [id]: state })); }, []); const clearControlState = useCallback((id: string) => { setControlStateByBot((prev) => { const next = { ...prev }; delete next[id]; return next; }); }, []); const stopBot = useCallback(async (id: string, status: string) => { if (status !== 'RUNNING') return; const startedAt = Date.now(); setOperatingBotId(id); setControlState(id, 'stopping'); try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`); updateBotStatus(id, 'STOPPED'); await refresh(); await ensureControlVisible(startedAt); } catch { notify(t.stopFail, { tone: 'error' }); await ensureControlVisible(startedAt); } finally { setOperatingBotId(null); clearControlState(id); } }, [clearControlState, ensureControlVisible, notify, refresh, setControlState, t.stopFail, updateBotStatus]); const startBot = useCallback(async (id: string, status: string) => { if (status === 'RUNNING') return; const startedAt = Date.now(); setOperatingBotId(id); setControlState(id, 'starting'); try { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`); updateBotStatus(id, 'RUNNING'); await refresh(); await ensureControlVisible(startedAt); } catch (error: unknown) { await refresh(); notify(resolveApiErrorMessage(error, t.startFail), { tone: 'error' }); await ensureControlVisible(startedAt); } finally { setOperatingBotId(null); clearControlState(id); } }, [clearControlState, ensureControlVisible, notify, refresh, setControlState, t.startFail, updateBotStatus]); const restartBot = useCallback(async (id: string, status: string) => { const normalized = String(status || '').toUpperCase(); const ok = await confirm({ title: t.restart, message: t.restartConfirm(id), tone: 'warning', }); if (!ok) return; const startedAt = Date.now(); setOperatingBotId(id); try { if (normalized === 'RUNNING') { setControlState(id, 'stopping'); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`); updateBotStatus(id, 'STOPPED'); } setControlState(id, 'starting'); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`); updateBotStatus(id, 'RUNNING'); await refresh(); await ensureControlVisible(startedAt); } catch (error: unknown) { await refresh(); notify(resolveApiErrorMessage(error, t.restartFail), { tone: 'error' }); await ensureControlVisible(startedAt); } finally { setOperatingBotId(null); clearControlState(id); } }, [clearControlState, confirm, ensureControlVisible, notify, refresh, setControlState, t, updateBotStatus]); const setBotEnabled = useCallback(async (id: string, enabled: boolean) => { setOperatingBotId(id); setControlState(id, enabled ? 'enabling' : 'disabling'); try { if (enabled) { await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/enable`); } else { const ok = await confirm({ title: t.disable, message: t.disableConfirm(id), tone: 'warning', }); if (!ok) return; await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/disable`); } await refresh(); notify(enabled ? t.enableDone : t.disableDone, { tone: 'success' }); } catch (error: unknown) { notify(resolveApiErrorMessage(error, enabled ? t.enableFail : t.disableFail), { tone: 'error' }); } finally { setOperatingBotId(null); clearControlState(id); } }, [clearControlState, confirm, notify, refresh, setControlState, t]); useEffect(() => { void loadImageOptions(); }, [loadImageOptions]); useEffect(() => { if (!showResourceModal || !resourceBotId) return; let stopped = false; const tick = async () => { if (stopped) return; await loadResourceSnapshot(resourceBotId); }; const timer = window.setInterval(() => { void tick(); }, 2000); return () => { stopped = true; window.clearInterval(timer); }; }, [loadResourceSnapshot, resourceBotId, showResourceModal]); return { availableImages, batchStartBots, batchStopBots, controlStateByBot, ensureSelectedBotDetail, isBatchOperating, loadWeixinLoginStatus, openResourceMonitor, operatingBotId, refresh, reloginWeixin, resourceBot, resourceBotId, resourceError, resourceLoading, resourceSnapshot, restartBot, setBotEnabled, setShowResourceModal, showResourceModal, startBot, stopBot, loadResourceSnapshot, weixinLoginStatus, }; }