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 ? (