diff --git a/control/backend/app/api/nodes.py b/control/backend/app/api/nodes.py index 3804475..36cefd8 100644 --- a/control/backend/app/api/nodes.py +++ b/control/backend/app/api/nodes.py @@ -222,6 +222,45 @@ async def update_batch( return {"results": results} +@router.post("/start-batch") +async def start_batch( + body: dict, + _user: Annotated[str, Depends(require_control_user)], +) -> dict: + """并行启动多台策略机(Fleet start)。""" + ids = body.get("ids") or [] + if not isinstance(ids, list) or not ids: + raise HTTPException(status_code=400, detail="ids 不能为空") + db = get_control_db() + + async def _one(nid: int) -> dict: + node = db.get_node(int(nid)) + if not node: + return {"id": nid, "ok": False, "detail": "不存在", "name": str(nid)} + name = str(node.get("name") or nid) + if not node.get("token_sealed"): + return {"id": nid, "ok": False, "detail": "未生成 Token", "name": name} + try: + code, data = await call_node(node, "POST", "/api/fleet/start", timeout=20.0) + except Exception as ex: + return {"id": nid, "ok": False, "detail": str(ex), "name": name} + detail = "" + if code >= 400: + detail = _http_detail(data) if data else f"HTTP {code}" + return { + "id": nid, + "name": name, + "ok": code < 400, + "status": code, + "detail": detail, + "result": data, + } + + items = list(await asyncio.gather(*[_one(int(x)) for x in ids])) + ok_n = sum(1 for x in items if x.get("ok")) + return {"ok": ok_n == len(items), "started": ok_n, "total": len(items), "results": items} + + @router.patch("/{node_id}") async def update_node( node_id: int, diff --git a/control/frontend/src/pages/Monitor.tsx b/control/frontend/src/pages/Monitor.tsx index 072645e..c25cdbf 100644 --- a/control/frontend/src/pages/Monitor.tsx +++ b/control/frontend/src/pages/Monitor.tsx @@ -238,6 +238,7 @@ export default function MonitorPage() { const [nodes, setNodes] = useState(() => cachedNodes); const [err, setErr] = useState(""); const [busy, setBusy] = useState>({}); + const [batchBusy, setBatchBusy] = useState(false); const [selected, setSelected] = useState>(new Set()); const [sseSec, setSseSec] = useState(cachedSseSec); const [liveState, setLiveState] = useState<"connecting" | "live" | "retry">( @@ -494,6 +495,56 @@ export default function MonitorPage() { await batchUpdate(updatableIds); } + const startableIds = nodes + .filter((n) => { + if (!n.token_configured) return false; + const s = pickStrategy(n); + const running = s.running === true || s.running === 1; + return !running; + }) + .map((n) => n.id); + + async function startAll() { + if (!startableIds.length) { + setErr("没有可启动的策略机(需已配对且当前未运行)"); + return; + } + const names = nodes + .filter((n) => startableIds.includes(n.id)) + .map((n) => n.name) + .join("、"); + const ok = window.confirm( + `确认全部启动 ${startableIds.length} 台策略机?\n\n${names}\n\n将并行调用各机 Fleet 启动(已在运行的不会列入)。`, + ); + if (!ok) return; + setErr(""); + setBatchBusy(true); + try { + const r = await apiFetch<{ + started?: number; + total?: number; + results?: { id: number; name?: string; ok: boolean; detail?: string }[]; + }>("/api/nodes/start-batch", { + method: "POST", + body: JSON.stringify({ ids: startableIds }), + }); + await refresh(); + const fails = (r.results || []).filter((x) => !x.ok); + if (fails.length) { + setErr( + `启动完成 ${r.started ?? 0}/${r.total ?? startableIds.length};失败:` + + fails + .map((x) => `${x.name || x.id}${x.detail ? `(${x.detail})` : ""}`) + .join(";"), + ); + } + } catch (ex) { + setErr(ex instanceof Error ? ex.message : String(ex)); + } finally { + setBatchBusy(false); + } + } + const detailNode = detailId != null ? nodes.find((x) => x.id === detailId) : null; const detail = detailNode ? pickStrategy(detailNode) : null; const detailRunning = @@ -534,6 +585,15 @@ export default function MonitorPage() { +