079dc83132
Co-authored-by: Cursor <cursoragent@cursor.com>
370 lines
12 KiB
Python
370 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import secrets
|
|
import time
|
|
from typing import Annotated, Any, AsyncIterator
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ..auth import require_control_user
|
|
from ..config import get_control_settings
|
|
from ..crypto import seal
|
|
from ..db import get_control_db
|
|
from ..proxy import call_node, probe_health
|
|
|
|
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
|
|
|
|
|
|
def _public_node(row: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": row["id"],
|
|
"name": row["name"],
|
|
"base_url": row["base_url"],
|
|
"token_configured": bool((row.get("token_sealed") or "").strip()),
|
|
"created_at_ms": row["created_at_ms"],
|
|
"updated_at_ms": row["updated_at_ms"],
|
|
}
|
|
|
|
|
|
def _http_detail(data: Any) -> str:
|
|
if isinstance(data, dict):
|
|
d = data.get("detail", data)
|
|
return d if isinstance(d, str) else str(d)
|
|
return str(data)
|
|
|
|
|
|
def _raise_node_error(code: int, data: Any) -> None:
|
|
"""策略机错误不得用 401 回传,否则中控前端会误清登录态。"""
|
|
detail = _http_detail(data)
|
|
if code in (401, 403):
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"策略机鉴权失败({detail})。请在中控重新生成 Token,并到策略机「系统设置→登录账户」保存同一 Token。",
|
|
)
|
|
raise HTTPException(
|
|
status_code=code if 400 <= code < 600 else 502,
|
|
detail=detail,
|
|
)
|
|
|
|
|
|
async def _collect_one_status(
|
|
node: dict[str, Any],
|
|
*,
|
|
timeout: float | None = None,
|
|
) -> dict[str, Any]:
|
|
"""有 Token 时只打 fleet/status(含在线+持仓),避免 /health 再多一跳。"""
|
|
base = _public_node(node)
|
|
if not node.get("token_sealed"):
|
|
probe = await probe_health(node, timeout=timeout)
|
|
return {
|
|
**base,
|
|
**probe,
|
|
"fleet_ok": False,
|
|
"fleet_error": "未生成 Token",
|
|
}
|
|
|
|
code, data = await call_node(
|
|
node, "GET", "/api/fleet/status", timeout=timeout
|
|
)
|
|
if code == 200 and isinstance(data, dict):
|
|
return {
|
|
**base,
|
|
"online": True,
|
|
"health": None,
|
|
"error": None,
|
|
"from_fleet": True,
|
|
"fleet": data,
|
|
"fleet_ok": True,
|
|
}
|
|
|
|
# fleet 失败时再探 /health,区分离线 vs Token 错误
|
|
probe = await probe_health(node, timeout=timeout)
|
|
item: dict[str, Any] = {
|
|
**base,
|
|
**probe,
|
|
"fleet_ok": False,
|
|
"fleet_error": _http_detail(data) if data else f"HTTP {code}",
|
|
}
|
|
return item
|
|
|
|
|
|
async def collect_all_status(*, timeout: float | None = None) -> list[dict[str, Any]]:
|
|
db = get_control_db()
|
|
nodes = db.list_nodes()
|
|
if not nodes:
|
|
return []
|
|
return list(
|
|
await asyncio.gather(
|
|
*[_collect_one_status(n, timeout=timeout) for n in nodes]
|
|
)
|
|
)
|
|
|
|
|
|
class NodeCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=64)
|
|
base_url: str = Field(min_length=8, max_length=256)
|
|
|
|
|
|
class NodeUpdate(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=64)
|
|
base_url: str | None = Field(default=None, min_length=8, max_length=256)
|
|
|
|
|
|
@router.get("/")
|
|
async def list_nodes(_user: Annotated[str, Depends(require_control_user)]) -> dict:
|
|
db = get_control_db()
|
|
nodes = [_public_node(n) for n in db.list_nodes()]
|
|
return {"nodes": nodes}
|
|
|
|
|
|
@router.post("/")
|
|
async def create_node(
|
|
body: NodeCreate,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> dict:
|
|
db = get_control_db()
|
|
try:
|
|
row = db.create_node(body.name, body.base_url)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"创建失败: {e}") from e
|
|
return _public_node(row)
|
|
|
|
|
|
@router.get("/status/all")
|
|
async def status_all(_user: Annotated[str, Depends(require_control_user)]) -> dict:
|
|
items = await collect_all_status()
|
|
return {"nodes": items}
|
|
|
|
|
|
@router.get("/status/stream")
|
|
async def status_stream(
|
|
request: Request,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> StreamingResponse:
|
|
"""SSE:中控约每秒并行拉取策略机状态并推送到浏览器。"""
|
|
settings = get_control_settings()
|
|
interval = max(0.5, float(settings.control_sse_interval_sec))
|
|
probe_timeout = max(0.5, float(settings.control_sse_probe_timeout_sec))
|
|
|
|
async def event_gen() -> AsyncIterator[str]:
|
|
last_payload = ""
|
|
last_heartbeat = 0.0
|
|
yield f": connected interval={interval}\n\n"
|
|
while True:
|
|
if await request.is_disconnected():
|
|
break
|
|
started = time.monotonic()
|
|
try:
|
|
items = await collect_all_status(timeout=probe_timeout)
|
|
payload = json.dumps(
|
|
{"nodes": items, "ts_ms": int(time.time() * 1000)},
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
default=str,
|
|
)
|
|
now = time.monotonic()
|
|
if payload != last_payload:
|
|
last_payload = payload
|
|
yield f"event: nodes\ndata: {payload}\n\n"
|
|
last_heartbeat = now
|
|
elif now - last_heartbeat >= 5.0:
|
|
yield f": heartbeat {int(time.time())}\n\n"
|
|
last_heartbeat = now
|
|
except Exception as e:
|
|
err = json.dumps(
|
|
{"detail": str(e)},
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
)
|
|
yield f"event: error\ndata: {err}\n\n"
|
|
elapsed = time.monotonic() - started
|
|
await asyncio.sleep(max(0.05, interval - elapsed))
|
|
|
|
return StreamingResponse(
|
|
event_gen(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/update-batch")
|
|
async def update_batch(
|
|
body: dict,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> dict:
|
|
ids = body.get("ids") or []
|
|
if not isinstance(ids, list) or not ids:
|
|
raise HTTPException(status_code=400, detail="ids 不能为空")
|
|
db = get_control_db()
|
|
results = []
|
|
for nid in ids:
|
|
node = db.get_node(int(nid))
|
|
if not node:
|
|
results.append({"id": nid, "ok": False, "detail": "不存在"})
|
|
continue
|
|
code, data = await call_node(node, "POST", "/api/fleet/update")
|
|
results.append(
|
|
{
|
|
"id": nid,
|
|
"ok": code < 400,
|
|
"status": code,
|
|
"result": data,
|
|
}
|
|
)
|
|
return {"results": results}
|
|
|
|
|
|
@router.patch("/{node_id}")
|
|
async def update_node(
|
|
node_id: int,
|
|
body: NodeUpdate,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> dict:
|
|
db = get_control_db()
|
|
row = db.update_node(node_id, name=body.name, base_url=body.base_url)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="节点不存在")
|
|
return _public_node(row)
|
|
|
|
|
|
@router.delete("/{node_id}")
|
|
async def delete_node(
|
|
node_id: int,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> dict:
|
|
db = get_control_db()
|
|
if not db.delete_node(node_id):
|
|
raise HTTPException(status_code=404, detail="节点不存在")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/{node_id}/generate-token")
|
|
async def generate_token(
|
|
node_id: int,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> dict:
|
|
"""生成新 Token,加密存中控;明文仅返回一次,需粘贴到策略机设置。"""
|
|
db = get_control_db()
|
|
node = db.get_node(node_id)
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="节点不存在")
|
|
plain = secrets.token_urlsafe(32)
|
|
sealed = seal(plain, get_control_settings().control_auth_secret)
|
|
db.update_node(node_id, token_sealed=sealed)
|
|
return {
|
|
"ok": True,
|
|
"token": plain,
|
|
"msg": "请立即复制并到策略机「系统设置 → 登录账户 → 中控 API Token」保存",
|
|
}
|
|
|
|
|
|
@router.get("/{node_id}/status")
|
|
async def node_status(
|
|
node_id: int,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> dict:
|
|
db = get_control_db()
|
|
node = db.get_node(node_id)
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="节点不存在")
|
|
return await _collect_one_status(node)
|
|
|
|
|
|
@router.get("/{node_id}/stats")
|
|
async def node_stats(
|
|
node_id: int,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> dict:
|
|
"""代理策略机整体统计(点击详情时按需拉取,不进 SSE)。"""
|
|
db = get_control_db()
|
|
node = db.get_node(node_id)
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="节点不存在")
|
|
if not node.get("token_sealed"):
|
|
raise HTTPException(status_code=400, detail="未生成 Token")
|
|
code, data = await call_node(node, "GET", "/api/fleet/stats", timeout=15.0)
|
|
if code >= 400:
|
|
_raise_node_error(code, data)
|
|
if not isinstance(data, dict):
|
|
raise HTTPException(status_code=502, detail="策略机统计返回异常")
|
|
return data
|
|
|
|
|
|
@router.post("/{node_id}/start")
|
|
async def node_start(
|
|
node_id: int,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> dict:
|
|
db = get_control_db()
|
|
node = db.get_node(node_id)
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="节点不存在")
|
|
code, data = await call_node(node, "POST", "/api/fleet/start")
|
|
if code >= 400:
|
|
_raise_node_error(code, data)
|
|
return {"ok": True, "result": data}
|
|
|
|
|
|
@router.post("/{node_id}/pause")
|
|
async def node_pause(
|
|
node_id: int,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> dict:
|
|
db = get_control_db()
|
|
node = db.get_node(node_id)
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="节点不存在")
|
|
code, data = await call_node(node, "POST", "/api/fleet/pause")
|
|
if code >= 400:
|
|
_raise_node_error(code, data)
|
|
return {"ok": True, "result": data}
|
|
|
|
|
|
@router.post("/{node_id}/update")
|
|
async def node_update(
|
|
node_id: int,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> dict:
|
|
db = get_control_db()
|
|
node = db.get_node(node_id)
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="节点不存在")
|
|
code, data = await call_node(node, "POST", "/api/fleet/update")
|
|
if code >= 400:
|
|
_raise_node_error(code, data)
|
|
return {"ok": True, "result": data}
|
|
|
|
|
|
@router.post("/{node_id}/login-url")
|
|
async def node_login_url(
|
|
node_id: int,
|
|
_user: Annotated[str, Depends(require_control_user)],
|
|
) -> dict:
|
|
"""用 Fleet Token 向策略机签发一次性登录票,返回可打开的 URL。"""
|
|
db = get_control_db()
|
|
node = db.get_node(node_id)
|
|
if not node:
|
|
raise HTTPException(status_code=404, detail="节点不存在")
|
|
code, data = await call_node(node, "POST", "/api/fleet/issue-login")
|
|
if code >= 400:
|
|
_raise_node_error(code, data)
|
|
path = ""
|
|
if isinstance(data, dict):
|
|
path = str(data.get("login_path") or "")
|
|
if not path:
|
|
raise HTTPException(status_code=502, detail="策略机未返回 login_path")
|
|
base = str(node["base_url"]).rstrip("/")
|
|
return {
|
|
"ok": True,
|
|
"url": f"{base}{path}",
|
|
"expires_in": data.get("expires_in") if isinstance(data, dict) else None,
|
|
}
|