v0.1.4-p4
parent
41212a7ac9
commit
1d20c8edb4
|
|
@ -1,7 +1,9 @@
|
|||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from sqlmodel import Session
|
||||
|
|
@ -21,6 +23,40 @@ router = APIRouter()
|
|||
logger = logging.getLogger("dashboard.backend")
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _compute_cron_next_run(schedule: Dict[str, Any], now_ms: Optional[int] = None) -> Optional[int]:
|
||||
current_ms = int(now_ms or _now_ms())
|
||||
kind = str(schedule.get("kind") or "").strip().lower()
|
||||
|
||||
if kind == "at":
|
||||
at_ms = int(schedule.get("atMs") or 0)
|
||||
return at_ms if at_ms > current_ms else None
|
||||
|
||||
if kind == "every":
|
||||
every_ms = int(schedule.get("everyMs") or 0)
|
||||
return current_ms + every_ms if every_ms > 0 else None
|
||||
|
||||
if kind == "cron":
|
||||
expr = str(schedule.get("expr") or "").strip()
|
||||
if not expr:
|
||||
return None
|
||||
try:
|
||||
from croniter import croniter
|
||||
|
||||
tz_name = str(schedule.get("tz") or "").strip()
|
||||
tz = ZoneInfo(tz_name) if tz_name else datetime.now().astimezone().tzinfo
|
||||
base_dt = datetime.fromtimestamp(current_ms / 1000, tz=tz)
|
||||
next_dt = croniter(expr, base_dt).get_next(datetime)
|
||||
return int(next_dt.timestamp() * 1000)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_bot_or_404(session: Session, bot_id: str) -> BotInstance:
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
|
|
@ -129,11 +165,43 @@ def stop_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_sessi
|
|||
if not found:
|
||||
raise HTTPException(status_code=404, detail="Cron job not found")
|
||||
found["enabled"] = False
|
||||
found["updatedAtMs"] = int(datetime.utcnow().timestamp() * 1000)
|
||||
found["updatedAtMs"] = _now_ms()
|
||||
state = found.get("state")
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
found["state"] = state
|
||||
state["nextRunAtMs"] = None
|
||||
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
|
||||
return {"status": "stopped", "job_id": job_id}
|
||||
|
||||
|
||||
@router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/start")
|
||||
def start_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
|
||||
_get_bot_or_404(session, bot_id)
|
||||
store = _read_cron_store(bot_id)
|
||||
jobs = store.get("jobs", [])
|
||||
if not isinstance(jobs, list):
|
||||
jobs = []
|
||||
found = None
|
||||
for row in jobs:
|
||||
if isinstance(row, dict) and str(row.get("id")) == job_id:
|
||||
found = row
|
||||
break
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail="Cron job not found")
|
||||
|
||||
found["enabled"] = True
|
||||
found["updatedAtMs"] = _now_ms()
|
||||
state = found.get("state")
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
found["state"] = state
|
||||
schedule = found.get("schedule")
|
||||
state["nextRunAtMs"] = _compute_cron_next_run(schedule if isinstance(schedule, dict) else {})
|
||||
_write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
|
||||
return {"status": "started", "job_id": job_id}
|
||||
|
||||
|
||||
@router.delete("/api/bots/{bot_id}/cron/jobs/{job_id}")
|
||||
def delete_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
|
||||
_get_bot_or_404(session, bot_id)
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ def _normalize_initial_channels(bot_id: str, channels: Optional[List[ChannelConf
|
|||
return rows
|
||||
|
||||
|
||||
def _sync_workspace_channels(
|
||||
def _sync_workspace_channels_impl(
|
||||
session: Session,
|
||||
bot_id: str,
|
||||
snapshot: Dict[str, Any],
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ from services.bot_channel_service import (
|
|||
_get_bot_channels_from_config,
|
||||
_normalize_channel_extra,
|
||||
_read_global_delivery_flags,
|
||||
_sync_workspace_channels,
|
||||
)
|
||||
from services.bot_service import _sync_workspace_channels
|
||||
from services.bot_mcp_service import (
|
||||
_merge_mcp_servers_preserving_extras,
|
||||
_normalize_mcp_servers,
|
||||
|
|
|
|||
|
|
@ -218,7 +218,9 @@ def list_bots_with_cache(session: Session) -> List[Dict[str, Any]]:
|
|||
cached = cache.get_json(_cache_key_bots_list())
|
||||
if isinstance(cached, list):
|
||||
return cached
|
||||
bots = session.exec(select(BotInstance)).all()
|
||||
bots = session.exec(
|
||||
select(BotInstance).order_by(BotInstance.created_at.desc(), BotInstance.id.asc())
|
||||
).all()
|
||||
dirty = False
|
||||
for bot in bots:
|
||||
actual_status = docker_manager.get_bot_status(bot.id)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ from services.bot_channel_service import (
|
|||
_normalize_channel_extra,
|
||||
_normalize_initial_channels,
|
||||
_read_global_delivery_flags,
|
||||
_sync_workspace_channels as _sync_workspace_channels_impl,
|
||||
_sync_workspace_channels_impl,
|
||||
)
|
||||
from services.bot_mcp_service import (
|
||||
_merge_mcp_servers_preserving_extras,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,16 @@ def _read_json_object(path: str) -> Dict[str, Any]:
|
|||
return {}
|
||||
|
||||
|
||||
def _read_json_value(path: str) -> Any:
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
return json.load(file)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp_path = f"{path}.tmp"
|
||||
|
|
@ -196,23 +206,32 @@ def _write_env_store(bot_id: str, env_params: Dict[str, str]) -> None:
|
|||
|
||||
|
||||
def _cron_store_path(bot_id: str) -> str:
|
||||
return os.path.join(_bot_data_root(bot_id), "cron", "jobs.json")
|
||||
return os.path.join(_workspace_root(bot_id), "cron", "jobs.json")
|
||||
|
||||
|
||||
def _normalize_cron_store_payload(raw: Any) -> Dict[str, Any]:
|
||||
if isinstance(raw, list):
|
||||
return {"version": 1, "jobs": [row for row in raw if isinstance(row, dict)]}
|
||||
if not isinstance(raw, dict):
|
||||
return {"version": 1, "jobs": []}
|
||||
jobs = raw.get("jobs")
|
||||
if isinstance(jobs, list):
|
||||
normalized_jobs = [row for row in jobs if isinstance(row, dict)]
|
||||
else:
|
||||
normalized_jobs = []
|
||||
return {
|
||||
"version": _safe_int(raw.get("version"), 1),
|
||||
"jobs": normalized_jobs,
|
||||
}
|
||||
|
||||
|
||||
def _read_cron_store(bot_id: str) -> Dict[str, Any]:
|
||||
data = _read_json_object(_cron_store_path(bot_id))
|
||||
if not data:
|
||||
return {"version": 1, "jobs": []}
|
||||
jobs = data.get("jobs")
|
||||
if not isinstance(jobs, list):
|
||||
data["jobs"] = []
|
||||
if "version" not in data:
|
||||
data["version"] = 1
|
||||
return data
|
||||
return _normalize_cron_store_payload(_read_json_value(_cron_store_path(bot_id)))
|
||||
|
||||
|
||||
def _write_cron_store(bot_id: str, store: Dict[str, Any]) -> None:
|
||||
_write_json_atomic(_cron_store_path(bot_id), store)
|
||||
normalized = _normalize_cron_store_payload(store)
|
||||
_write_json_atomic(_cron_store_path(bot_id), normalized)
|
||||
|
||||
|
||||
def _sessions_root(bot_id: str) -> str:
|
||||
|
|
|
|||
|
|
@ -281,8 +281,10 @@ export const dashboardEn = {
|
|||
cronEmpty: 'No scheduled jobs.',
|
||||
cronEnabled: 'Enabled',
|
||||
cronDisabled: 'Disabled',
|
||||
cronStart: 'Enable job',
|
||||
cronStop: 'Stop job',
|
||||
cronDelete: 'Delete job',
|
||||
cronStartFail: 'Failed to enable job.',
|
||||
cronStopFail: 'Failed to stop job.',
|
||||
cronDeleteFail: 'Failed to delete job.',
|
||||
cronDeleteConfirm: (id: string) => `Delete scheduled job ${id}?`,
|
||||
|
|
|
|||
|
|
@ -281,8 +281,10 @@ export const dashboardZhCn = {
|
|||
cronEmpty: '暂无定时任务。',
|
||||
cronEnabled: '启用',
|
||||
cronDisabled: '已停用',
|
||||
cronStart: '启用任务',
|
||||
cronStop: '停止任务',
|
||||
cronDelete: '删除任务',
|
||||
cronStartFail: '启用任务失败。',
|
||||
cronStopFail: '停止任务失败。',
|
||||
cronDeleteFail: '删除任务失败。',
|
||||
cronDeleteConfirm: (id: string) => `确认删除任务 ${id}?`,
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ export function BotListPanel({
|
|||
const isEnabling = controlState === 'enabling';
|
||||
const isDisabling = controlState === 'disabling';
|
||||
const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING';
|
||||
const showActionPending = isOperating && !isEnabling && !isDisabling;
|
||||
return (
|
||||
<div
|
||||
key={bot.id}
|
||||
|
|
@ -290,7 +291,7 @@ export function BotListPanel({
|
|||
tooltip={isRunning ? labels.stop : labels.start}
|
||||
aria-label={isRunning ? labels.stop : labels.start}
|
||||
>
|
||||
{isStarting || isStopping ? (
|
||||
{showActionPending ? (
|
||||
<span className="ops-control-pending">
|
||||
<span className="ops-control-dots" aria-hidden="true">
|
||||
<i />
|
||||
|
|
|
|||
|
|
@ -111,3 +111,69 @@
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ops-cron-action-stop {
|
||||
background: color-mix(in oklab, #f5af48 30%, var(--panel-soft) 70%);
|
||||
border-color: color-mix(in oklab, #f5af48 58%, var(--line) 42%);
|
||||
color: #5e3b00;
|
||||
}
|
||||
|
||||
.ops-cron-action-start {
|
||||
background: color-mix(in oklab, var(--ok) 24%, var(--panel-soft) 76%);
|
||||
border-color: color-mix(in oklab, var(--ok) 52%, var(--line) 48%);
|
||||
color: color-mix(in oklab, var(--text) 76%, white 24%);
|
||||
}
|
||||
|
||||
.ops-cron-action-start:hover {
|
||||
background: color-mix(in oklab, var(--ok) 32%, var(--panel-soft) 68%);
|
||||
border-color: color-mix(in oklab, var(--ok) 64%, var(--line) 36%);
|
||||
}
|
||||
|
||||
.ops-cron-action-stop:hover {
|
||||
background: color-mix(in oklab, #f5af48 38%, var(--panel-soft) 62%);
|
||||
border-color: color-mix(in oklab, #f5af48 70%, var(--line) 30%);
|
||||
}
|
||||
|
||||
.ops-cron-control-pending {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.ops-cron-control-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ops-cron-control-dots i {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
opacity: 0.38;
|
||||
animation: ops-cron-dot-pulse 1s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.ops-cron-control-dots i:nth-child(2) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
|
||||
.ops-cron-control-dots i:nth-child(3) {
|
||||
animation-delay: 0.24s;
|
||||
}
|
||||
|
||||
@keyframes ops-cron-dot-pulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.24;
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Clock3, Plus, PowerOff, RefreshCw, Save, Trash2, X } from 'lucide-react';
|
||||
import { Clock3, Plus, Power, RefreshCw, Save, Square, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { DrawerShell } from '../../../components/DrawerShell';
|
||||
import { PasswordInput } from '../../../components/PasswordInput';
|
||||
|
|
@ -37,6 +37,7 @@ interface CronModalLabels {
|
|||
cronEnabled: string;
|
||||
cronLoading: string;
|
||||
cronReload: string;
|
||||
cronStart: string;
|
||||
cronStop: string;
|
||||
cronViewer: string;
|
||||
}
|
||||
|
|
@ -299,11 +300,13 @@ interface CronJobsModalProps {
|
|||
cronLoading: boolean;
|
||||
cronJobs: CronJob[];
|
||||
cronActionJobId: string;
|
||||
cronActionType?: 'starting' | 'stopping' | 'deleting' | '';
|
||||
isZh: boolean;
|
||||
labels: CronModalLabels;
|
||||
formatCronSchedule: (job: CronJob, isZh: boolean) => string;
|
||||
onClose: () => void;
|
||||
onReload: () => Promise<void> | void;
|
||||
onStartJob: (jobId: string) => Promise<void> | void;
|
||||
onStopJob: (jobId: string) => Promise<void> | void;
|
||||
onDeleteJob: (jobId: string) => Promise<void> | void;
|
||||
}
|
||||
|
|
@ -313,11 +316,13 @@ export function CronJobsModal({
|
|||
cronLoading,
|
||||
cronJobs,
|
||||
cronActionJobId,
|
||||
cronActionType,
|
||||
isZh,
|
||||
labels,
|
||||
formatCronSchedule,
|
||||
onClose,
|
||||
onReload,
|
||||
onStartJob,
|
||||
onStopJob,
|
||||
onDeleteJob,
|
||||
}: CronJobsModalProps) {
|
||||
|
|
@ -352,7 +357,8 @@ export function CronJobsModal({
|
|||
) : (
|
||||
<div className="ops-cron-list ops-cron-list-scroll">
|
||||
{cronJobs.map((job) => {
|
||||
const stopping = cronActionJobId === job.id;
|
||||
const acting = cronActionJobId === job.id && Boolean(cronActionType);
|
||||
const enabled = job.enabled !== false;
|
||||
const channel = String(job.payload?.channel || '').trim();
|
||||
const to = String(job.payload?.to || '').trim();
|
||||
const target = channel && to ? `${channel}:${to}` : channel || to || '-';
|
||||
|
|
@ -373,20 +379,28 @@ export function CronJobsModal({
|
|||
</div>
|
||||
<div className="ops-cron-actions">
|
||||
<LucentIconButton
|
||||
className="btn btn-danger btn-sm icon-btn"
|
||||
onClick={() => void onStopJob(job.id)}
|
||||
tooltip={labels.cronStop}
|
||||
aria-label={labels.cronStop}
|
||||
disabled={stopping || job.enabled === false}
|
||||
className={`btn btn-sm icon-btn ${enabled ? 'ops-cron-action-stop' : 'ops-cron-action-start'}`}
|
||||
onClick={() => void (enabled ? onStopJob(job.id) : onStartJob(job.id))}
|
||||
tooltip={enabled ? labels.cronStop : labels.cronStart}
|
||||
aria-label={enabled ? labels.cronStop : labels.cronStart}
|
||||
disabled={acting}
|
||||
>
|
||||
<PowerOff size={13} />
|
||||
{acting ? (
|
||||
<span className="ops-cron-control-pending">
|
||||
<span className="ops-cron-control-dots" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</span>
|
||||
) : enabled ? <Square size={13} /> : <Power size={13} />}
|
||||
</LucentIconButton>
|
||||
<LucentIconButton
|
||||
className="btn btn-danger btn-sm icon-btn"
|
||||
onClick={() => void onDeleteJob(job.id)}
|
||||
tooltip={labels.cronDelete}
|
||||
aria-label={labels.cronDelete}
|
||||
disabled={stopping}
|
||||
disabled={acting}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</LucentIconButton>
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ export function useBotDashboardModule({
|
|||
const {
|
||||
botSkills,
|
||||
cronActionJobId,
|
||||
cronActionType,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
createEnvParam,
|
||||
|
|
@ -218,6 +219,7 @@ export function useBotDashboardModule({
|
|||
removeBotSkill,
|
||||
resetSupportState,
|
||||
saveSingleEnvParam,
|
||||
startCronJob,
|
||||
setTopicFeedTopicKey,
|
||||
stopCronJob,
|
||||
topicFeedDeleteSavingById,
|
||||
|
|
@ -261,6 +263,7 @@ export function useBotDashboardModule({
|
|||
closeRuntimeMenu: () => setRuntimeMenuOpen(false),
|
||||
confirm,
|
||||
cronActionJobId,
|
||||
cronActionType,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
createEnvParam,
|
||||
|
|
@ -291,6 +294,7 @@ export function useBotDashboardModule({
|
|||
saveSingleEnvParam,
|
||||
selectedBot,
|
||||
selectedBotId,
|
||||
startCronJob,
|
||||
stopCronJob,
|
||||
t,
|
||||
weixinLoginStatus,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ interface UseDashboardConfigPanelsOptions {
|
|||
botSkills: WorkspaceSkillOption[];
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
cronActionJobId: string | null;
|
||||
cronActionType?: 'starting' | 'stopping' | 'deleting' | '';
|
||||
cronJobs: any[];
|
||||
cronLoading: boolean;
|
||||
createEnvParam: (key: string, value: string) => Promise<boolean>;
|
||||
|
|
@ -58,6 +59,7 @@ interface UseDashboardConfigPanelsOptions {
|
|||
saveSingleEnvParam: (originalKey: string, nextKey: string, nextValue: string) => Promise<boolean>;
|
||||
selectedBot?: any;
|
||||
selectedBotId: string;
|
||||
startCronJob: (jobId: string) => Promise<void>;
|
||||
stopCronJob: (jobId: string) => Promise<void>;
|
||||
t: any;
|
||||
lc: any;
|
||||
|
|
@ -69,6 +71,7 @@ export function useDashboardConfigPanels({
|
|||
botSkills,
|
||||
confirm,
|
||||
cronActionJobId,
|
||||
cronActionType,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
createEnvParam,
|
||||
|
|
@ -98,6 +101,7 @@ export function useDashboardConfigPanels({
|
|||
saveSingleEnvParam,
|
||||
selectedBot,
|
||||
selectedBotId,
|
||||
startCronJob,
|
||||
stopCronJob,
|
||||
t,
|
||||
lc,
|
||||
|
|
@ -450,6 +454,7 @@ export function useDashboardConfigPanels({
|
|||
|
||||
const cronJobsModalProps = buildCronJobsModalProps({
|
||||
cronActionJobId,
|
||||
cronActionType,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
deleteCronJob,
|
||||
|
|
@ -462,12 +467,14 @@ export function useDashboardConfigPanels({
|
|||
cronEnabled: t.cronEnabled,
|
||||
cronLoading: t.cronLoading,
|
||||
cronReload: t.cronReload,
|
||||
cronStart: t.cronStart,
|
||||
cronStop: t.cronStop,
|
||||
cronViewer: t.cronViewer,
|
||||
},
|
||||
loadCronJobs,
|
||||
onClose: () => setShowCronModal(false),
|
||||
selectedBot,
|
||||
startCronJob,
|
||||
stopCronJob,
|
||||
open: showCronModal,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export function useDashboardRuntimeControl({
|
|||
notify,
|
||||
confirm,
|
||||
}: UseDashboardRuntimeControlOptions) {
|
||||
const CONTROL_MIN_VISIBLE_MS = 450;
|
||||
const [showResourceModal, setShowResourceModal] = useState(false);
|
||||
const [resourceBotId, setResourceBotId] = useState('');
|
||||
const [resourceSnapshot, setResourceSnapshot] = useState<BotResourceSnapshot | null>(null);
|
||||
|
|
@ -239,16 +240,27 @@ export function useDashboardRuntimeControl({
|
|||
}
|
||||
}, [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 stopBot = useCallback(async (id: string, status: string) => {
|
||||
if (status !== 'RUNNING') return;
|
||||
const startedAt = Date.now();
|
||||
setOperatingBotId(id);
|
||||
setControlStateByBot((prev) => ({ ...prev, [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);
|
||||
setControlStateByBot((prev) => {
|
||||
|
|
@ -257,19 +269,22 @@ export function useDashboardRuntimeControl({
|
|||
return next;
|
||||
});
|
||||
}
|
||||
}, [notify, refresh, t.stopFail, updateBotStatus]);
|
||||
}, [ensureControlVisible, notify, refresh, t.stopFail, updateBotStatus]);
|
||||
|
||||
const startBot = useCallback(async (id: string, status: string) => {
|
||||
if (status === 'RUNNING') return;
|
||||
const startedAt = Date.now();
|
||||
setOperatingBotId(id);
|
||||
setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' }));
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`);
|
||||
updateBotStatus(id, 'RUNNING');
|
||||
await refresh();
|
||||
await ensureControlVisible(startedAt);
|
||||
} catch (error: any) {
|
||||
await refresh();
|
||||
notify(error?.response?.data?.detail || t.startFail, { tone: 'error' });
|
||||
await ensureControlVisible(startedAt);
|
||||
} finally {
|
||||
setOperatingBotId(null);
|
||||
setControlStateByBot((prev) => {
|
||||
|
|
@ -278,7 +293,7 @@ export function useDashboardRuntimeControl({
|
|||
return next;
|
||||
});
|
||||
}
|
||||
}, [notify, refresh, t.startFail, updateBotStatus]);
|
||||
}, [ensureControlVisible, notify, refresh, t.startFail, updateBotStatus]);
|
||||
|
||||
const restartBot = useCallback(async (id: string, status: string) => {
|
||||
const normalized = String(status || '').toUpperCase();
|
||||
|
|
@ -288,6 +303,7 @@ export function useDashboardRuntimeControl({
|
|||
tone: 'warning',
|
||||
});
|
||||
if (!ok) return;
|
||||
const startedAt = Date.now();
|
||||
setOperatingBotId(id);
|
||||
try {
|
||||
if (normalized === 'RUNNING') {
|
||||
|
|
@ -299,9 +315,11 @@ export function useDashboardRuntimeControl({
|
|||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`);
|
||||
updateBotStatus(id, 'RUNNING');
|
||||
await refresh();
|
||||
await ensureControlVisible(startedAt);
|
||||
} catch (error: any) {
|
||||
await refresh();
|
||||
notify(error?.response?.data?.detail || t.restartFail, { tone: 'error' });
|
||||
await ensureControlVisible(startedAt);
|
||||
} finally {
|
||||
setOperatingBotId(null);
|
||||
setControlStateByBot((prev) => {
|
||||
|
|
@ -310,7 +328,7 @@ export function useDashboardRuntimeControl({
|
|||
return next;
|
||||
});
|
||||
}
|
||||
}, [confirm, notify, refresh, t, updateBotStatus]);
|
||||
}, [confirm, ensureControlVisible, notify, refresh, t, updateBotStatus]);
|
||||
|
||||
const setBotEnabled = useCallback(async (id: string, enabled: boolean) => {
|
||||
setOperatingBotId(id);
|
||||
|
|
|
|||
|
|
@ -36,40 +36,14 @@ export function useDashboardShellState({
|
|||
const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const botListMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const controlCommandPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const botOrderRef = useRef<Record<string, number>>({});
|
||||
const nextBotOrderRef = useRef(1);
|
||||
|
||||
useEffect(() => {
|
||||
const ordered = Object.values(activeBots).sort((a, b) => {
|
||||
const aCreated = parseBotTimestamp(a.created_at);
|
||||
const bCreated = parseBotTimestamp(b.created_at);
|
||||
if (aCreated !== bCreated) return aCreated - bCreated;
|
||||
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
|
||||
ordered.forEach((bot) => {
|
||||
const id = String(bot.id || '').trim();
|
||||
if (!id) return;
|
||||
if (botOrderRef.current[id] !== undefined) return;
|
||||
botOrderRef.current[id] = nextBotOrderRef.current;
|
||||
nextBotOrderRef.current += 1;
|
||||
});
|
||||
|
||||
const alive = new Set(ordered.map((bot) => String(bot.id || '').trim()).filter(Boolean));
|
||||
Object.keys(botOrderRef.current).forEach((id) => {
|
||||
if (!alive.has(id)) delete botOrderRef.current[id];
|
||||
});
|
||||
}, [activeBots]);
|
||||
|
||||
const bots = useMemo(
|
||||
() =>
|
||||
Object.values(activeBots).sort((a, b) => {
|
||||
const aId = String(a.id || '').trim();
|
||||
const bId = String(b.id || '').trim();
|
||||
const aOrder = botOrderRef.current[aId] ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = botOrderRef.current[bId] ?? Number.MAX_SAFE_INTEGER;
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
return aId.localeCompare(bId);
|
||||
const aCreated = parseBotTimestamp(a.created_at);
|
||||
const bCreated = parseBotTimestamp(b.created_at);
|
||||
if (aCreated !== bCreated) return bCreated - aCreated;
|
||||
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||
}),
|
||||
[activeBots],
|
||||
);
|
||||
|
|
@ -166,7 +140,6 @@ export function useDashboardShellState({
|
|||
botListPage,
|
||||
botListTotalPages,
|
||||
botListQuery,
|
||||
botOrderRef,
|
||||
bots,
|
||||
compactListFirstMode,
|
||||
compactPanelTab,
|
||||
|
|
@ -177,7 +150,6 @@ export function useDashboardShellState({
|
|||
hasForcedBot,
|
||||
isCompactListPage,
|
||||
isCompactMobile,
|
||||
nextBotOrderRef,
|
||||
normalizedBotListQuery,
|
||||
pagedBots,
|
||||
runtimeMenuOpen,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export function useDashboardSupportData({
|
|||
const [cronJobs, setCronJobs] = useState<CronJob[]>([]);
|
||||
const [cronLoading, setCronLoading] = useState(false);
|
||||
const [cronActionJobId, setCronActionJobId] = useState('');
|
||||
const [cronActionType, setCronActionType] = useState<'' | 'starting' | 'stopping' | 'deleting'>('');
|
||||
const [botSkills, setBotSkills] = useState<WorkspaceSkillOption[]>([]);
|
||||
const [marketSkills, setMarketSkills] = useState<BotSkillMarketItem[]>([]);
|
||||
const [isSkillUploading, setIsSkillUploading] = useState(false);
|
||||
|
|
@ -79,6 +80,7 @@ export function useDashboardSupportData({
|
|||
setCronJobs([]);
|
||||
setCronLoading(false);
|
||||
setCronActionJobId('');
|
||||
setCronActionType('');
|
||||
setBotSkills([]);
|
||||
setMarketSkills([]);
|
||||
setIsSkillUploading(false);
|
||||
|
|
@ -391,9 +393,25 @@ export function useDashboardSupportData({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const startCronJob = useCallback(async (jobId: string) => {
|
||||
if (!selectedBotId || !jobId) return;
|
||||
setCronActionJobId(jobId);
|
||||
setCronActionType('starting');
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/cron/jobs/${jobId}/start`);
|
||||
await loadCronJobs(selectedBotId);
|
||||
} catch (error: any) {
|
||||
notify(error?.response?.data?.detail || t.cronStartFail, { tone: 'error' });
|
||||
} finally {
|
||||
setCronActionJobId('');
|
||||
setCronActionType('');
|
||||
}
|
||||
}, [loadCronJobs, notify, selectedBotId, t.cronStartFail]);
|
||||
|
||||
const stopCronJob = useCallback(async (jobId: string) => {
|
||||
if (!selectedBotId || !jobId) return;
|
||||
setCronActionJobId(jobId);
|
||||
setCronActionType('stopping');
|
||||
try {
|
||||
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/cron/jobs/${jobId}/stop`);
|
||||
await loadCronJobs(selectedBotId);
|
||||
|
|
@ -401,6 +419,7 @@ export function useDashboardSupportData({
|
|||
notify(error?.response?.data?.detail || t.cronStopFail, { tone: 'error' });
|
||||
} finally {
|
||||
setCronActionJobId('');
|
||||
setCronActionType('');
|
||||
}
|
||||
}, [loadCronJobs, notify, selectedBotId, t.cronStopFail]);
|
||||
|
||||
|
|
@ -413,6 +432,7 @@ export function useDashboardSupportData({
|
|||
});
|
||||
if (!ok) return;
|
||||
setCronActionJobId(jobId);
|
||||
setCronActionType('deleting');
|
||||
try {
|
||||
await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/cron/jobs/${jobId}`);
|
||||
await loadCronJobs(selectedBotId);
|
||||
|
|
@ -420,12 +440,14 @@ export function useDashboardSupportData({
|
|||
notify(error?.response?.data?.detail || t.cronDeleteFail, { tone: 'error' });
|
||||
} finally {
|
||||
setCronActionJobId('');
|
||||
setCronActionType('');
|
||||
}
|
||||
}, [confirm, loadCronJobs, notify, selectedBotId, t.cronDelete, t.cronDeleteConfirm, t.cronDeleteFail]);
|
||||
|
||||
return {
|
||||
botSkills,
|
||||
cronActionJobId,
|
||||
cronActionType,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
createEnvParam,
|
||||
|
|
@ -451,6 +473,7 @@ export function useDashboardSupportData({
|
|||
resetSupportState,
|
||||
saveBotEnvParams,
|
||||
saveSingleEnvParam,
|
||||
startCronJob,
|
||||
setTopicFeedTopicKey,
|
||||
stopCronJob,
|
||||
topicFeedDeleteSavingById,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export function buildEnvParamsModalProps(options: {
|
|||
|
||||
export function buildCronJobsModalProps(options: {
|
||||
cronActionJobId: string | null;
|
||||
cronActionType?: 'starting' | 'stopping' | 'deleting' | '';
|
||||
cronJobs: any[];
|
||||
cronLoading: boolean;
|
||||
deleteCronJob: (jobId: string) => Promise<void>;
|
||||
|
|
@ -55,11 +56,13 @@ export function buildCronJobsModalProps(options: {
|
|||
loadCronJobs: (botId: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
selectedBot?: any;
|
||||
startCronJob: (jobId: string) => Promise<void>;
|
||||
stopCronJob: (jobId: string) => Promise<void>;
|
||||
open: boolean;
|
||||
}): ComponentProps<typeof CronJobsModal> {
|
||||
const {
|
||||
cronActionJobId,
|
||||
cronActionType,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
deleteCronJob,
|
||||
|
|
@ -68,6 +71,7 @@ export function buildCronJobsModalProps(options: {
|
|||
loadCronJobs,
|
||||
onClose,
|
||||
selectedBot,
|
||||
startCronJob,
|
||||
stopCronJob,
|
||||
open,
|
||||
} = options;
|
||||
|
|
@ -77,11 +81,13 @@ export function buildCronJobsModalProps(options: {
|
|||
cronLoading,
|
||||
cronJobs,
|
||||
cronActionJobId: cronActionJobId || '',
|
||||
cronActionType: cronActionType || '',
|
||||
isZh,
|
||||
labels,
|
||||
formatCronSchedule,
|
||||
onClose,
|
||||
onReload: () => selectedBot && loadCronJobs(selectedBot.id),
|
||||
onStartJob: startCronJob,
|
||||
onStopJob: stopCronJob,
|
||||
onDeleteJob: deleteCronJob,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue