dashboard-nanobot/frontend/src/modules/dashboard/hooks/useDashboardRuntimeControl.ts

409 lines
13 KiB
TypeScript

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<boolean>;
}
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<BotResourceSnapshot | null>(null);
const [resourceLoading, setResourceLoading] = useState(false);
const [resourceError, setResourceError] = useState('');
const [operatingBotId, setOperatingBotId] = useState<string | null>(null);
const [controlStateByBot, setControlStateByBot] = useState<Record<string, BotControlState>>({});
const [isBatchOperating, setIsBatchOperating] = useState(false);
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
const [weixinLoginStatus, setWeixinLoginStatus] = useState<WeixinLoginStatus | null>(null);
const resourceBot = useMemo(
() => bots.find((bot) => bot.id === resourceBotId),
[bots, resourceBotId],
);
const loadImageOptions = useCallback(async () => {
const [imagesRes] = await Promise.allSettled([
axios.get<NanobotImage[]>(`${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<BotResourceSnapshot>(`${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,
};
}