diff --git a/backend/api/bot_runtime_router.py b/backend/api/bot_runtime_router.py
index fab47d8..e40fd95 100644
--- a/backend/api/bot_runtime_router.py
+++ b/backend/api/bot_runtime_router.py
@@ -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)
diff --git a/backend/services/bot_channel_service.py b/backend/services/bot_channel_service.py
index 4ba3ca0..6423ab8 100644
--- a/backend/services/bot_channel_service.py
+++ b/backend/services/bot_channel_service.py
@@ -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],
diff --git a/backend/services/bot_config_service.py b/backend/services/bot_config_service.py
index be308be..de6b3b7 100644
--- a/backend/services/bot_config_service.py
+++ b/backend/services/bot_config_service.py
@@ -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,
diff --git a/backend/services/bot_management_service.py b/backend/services/bot_management_service.py
index 5322dc1..3d6cdc7 100644
--- a/backend/services/bot_management_service.py
+++ b/backend/services/bot_management_service.py
@@ -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)
diff --git a/backend/services/bot_service.py b/backend/services/bot_service.py
index 672e2f8..2e26818 100644
--- a/backend/services/bot_service.py
+++ b/backend/services/bot_service.py
@@ -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,
diff --git a/backend/services/bot_storage_service.py b/backend/services/bot_storage_service.py
index 2b5aef5..569f338 100644
--- a/backend/services/bot_storage_service.py
+++ b/backend/services/bot_storage_service.py
@@ -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:
diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts
index 075eafc..6396845 100644
--- a/frontend/src/i18n/dashboard.en.ts
+++ b/frontend/src/i18n/dashboard.en.ts
@@ -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}?`,
diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts
index 8c2b4a5..e9c4949 100644
--- a/frontend/src/i18n/dashboard.zh-cn.ts
+++ b/frontend/src/i18n/dashboard.zh-cn.ts
@@ -281,8 +281,10 @@ export const dashboardZhCn = {
cronEmpty: '暂无定时任务。',
cronEnabled: '启用',
cronDisabled: '已停用',
+ cronStart: '启用任务',
cronStop: '停止任务',
cronDelete: '删除任务',
+ cronStartFail: '启用任务失败。',
cronStopFail: '停止任务失败。',
cronDeleteFail: '删除任务失败。',
cronDeleteConfirm: (id: string) => `确认删除任务 ${id}?`,
diff --git a/frontend/src/modules/dashboard/components/BotListPanel.tsx b/frontend/src/modules/dashboard/components/BotListPanel.tsx
index 6bd167d..1c82667 100644
--- a/frontend/src/modules/dashboard/components/BotListPanel.tsx
+++ b/frontend/src/modules/dashboard/components/BotListPanel.tsx
@@ -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 (
- {isStarting || isStopping ? (
+ {showActionPending ? (
diff --git a/frontend/src/modules/dashboard/components/DashboardSupportModals.css b/frontend/src/modules/dashboard/components/DashboardSupportModals.css
index 624b015..2988153 100644
--- a/frontend/src/modules/dashboard/components/DashboardSupportModals.css
+++ b/frontend/src/modules/dashboard/components/DashboardSupportModals.css
@@ -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);
+ }
+}
diff --git a/frontend/src/modules/dashboard/components/DashboardSupportModals.tsx b/frontend/src/modules/dashboard/components/DashboardSupportModals.tsx
index 02d3e72..2f32817 100644
--- a/frontend/src/modules/dashboard/components/DashboardSupportModals.tsx
+++ b/frontend/src/modules/dashboard/components/DashboardSupportModals.tsx
@@ -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;
+ onStartJob: (jobId: string) => Promise | void;
onStopJob: (jobId: string) => Promise | void;
onDeleteJob: (jobId: string) => Promise | 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({
) : (
{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({
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}
>
-
+ {acting ? (
+
+
+
+
+
+
+
+ ) : enabled ? : }
void onDeleteJob(job.id)}
tooltip={labels.cronDelete}
aria-label={labels.cronDelete}
- disabled={stopping}
+ disabled={acting}
>
diff --git a/frontend/src/modules/dashboard/hooks/useBotDashboardModule.ts b/frontend/src/modules/dashboard/hooks/useBotDashboardModule.ts
index 0cc43a2..f76bbd8 100644
--- a/frontend/src/modules/dashboard/hooks/useBotDashboardModule.ts
+++ b/frontend/src/modules/dashboard/hooks/useBotDashboardModule.ts
@@ -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,
diff --git a/frontend/src/modules/dashboard/hooks/useDashboardConfigPanels.ts b/frontend/src/modules/dashboard/hooks/useDashboardConfigPanels.ts
index 80d8c59..3e20d59 100644
--- a/frontend/src/modules/dashboard/hooks/useDashboardConfigPanels.ts
+++ b/frontend/src/modules/dashboard/hooks/useDashboardConfigPanels.ts
@@ -29,6 +29,7 @@ interface UseDashboardConfigPanelsOptions {
botSkills: WorkspaceSkillOption[];
confirm: (options: ConfirmOptions) => Promise
;
cronActionJobId: string | null;
+ cronActionType?: 'starting' | 'stopping' | 'deleting' | '';
cronJobs: any[];
cronLoading: boolean;
createEnvParam: (key: string, value: string) => Promise;
@@ -58,6 +59,7 @@ interface UseDashboardConfigPanelsOptions {
saveSingleEnvParam: (originalKey: string, nextKey: string, nextValue: string) => Promise;
selectedBot?: any;
selectedBotId: string;
+ startCronJob: (jobId: string) => Promise;
stopCronJob: (jobId: string) => Promise;
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,
});
diff --git a/frontend/src/modules/dashboard/hooks/useDashboardRuntimeControl.ts b/frontend/src/modules/dashboard/hooks/useDashboardRuntimeControl.ts
index cf4d13b..7b0a0b6 100644
--- a/frontend/src/modules/dashboard/hooks/useDashboardRuntimeControl.ts
+++ b/frontend/src/modules/dashboard/hooks/useDashboardRuntimeControl.ts
@@ -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(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);
diff --git a/frontend/src/modules/dashboard/hooks/useDashboardShellState.ts b/frontend/src/modules/dashboard/hooks/useDashboardShellState.ts
index 04bb778..0636e6b 100644
--- a/frontend/src/modules/dashboard/hooks/useDashboardShellState.ts
+++ b/frontend/src/modules/dashboard/hooks/useDashboardShellState.ts
@@ -36,40 +36,14 @@ export function useDashboardShellState({
const runtimeMenuRef = useRef(null);
const botListMenuRef = useRef(null);
const controlCommandPanelRef = useRef(null);
- const botOrderRef = useRef>({});
- 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,
diff --git a/frontend/src/modules/dashboard/hooks/useDashboardSupportData.ts b/frontend/src/modules/dashboard/hooks/useDashboardSupportData.ts
index f7565ee..e92a28f 100644
--- a/frontend/src/modules/dashboard/hooks/useDashboardSupportData.ts
+++ b/frontend/src/modules/dashboard/hooks/useDashboardSupportData.ts
@@ -51,6 +51,7 @@ export function useDashboardSupportData({
const [cronJobs, setCronJobs] = useState([]);
const [cronLoading, setCronLoading] = useState(false);
const [cronActionJobId, setCronActionJobId] = useState('');
+ const [cronActionType, setCronActionType] = useState<'' | 'starting' | 'stopping' | 'deleting'>('');
const [botSkills, setBotSkills] = useState([]);
const [marketSkills, setMarketSkills] = useState([]);
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,
diff --git a/frontend/src/modules/dashboard/shared/config-panel-modal-props/supportModalProps.ts b/frontend/src/modules/dashboard/shared/config-panel-modal-props/supportModalProps.ts
index 15ec15c..00b597c 100644
--- a/frontend/src/modules/dashboard/shared/config-panel-modal-props/supportModalProps.ts
+++ b/frontend/src/modules/dashboard/shared/config-panel-modal-props/supportModalProps.ts
@@ -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;
@@ -55,11 +56,13 @@ export function buildCronJobsModalProps(options: {
loadCronJobs: (botId: string) => Promise;
onClose: () => void;
selectedBot?: any;
+ startCronJob: (jobId: string) => Promise;
stopCronJob: (jobId: string) => Promise;
open: boolean;
}): ComponentProps {
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,
};