409 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|