Files
Nexus/server/api/watch.py
T
Nexus Agent 32a1885f0d feat(watch): 监测槽开关、8h/24h、到期暂停与探针修复
支持随时暂停/恢复实时监测并保留槽位;TTL 到期自动暂停不占删槽;修复到期竞态导致空槽与唯一约束冲突;探针 parse_error 与中文错误;移除失败 snackbar。
2026-06-12 03:47:56 +08:00

238 lines
7.0 KiB
Python

"""Watch slot API — pin CRUD and live metrics."""
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from server.api.auth_jwt import get_current_admin
from server.api.dependencies import get_db
from server.application.services.watch_service import SlotsFullError, WatchService
from server.domain.models import Admin
from server.utils.watch_metrics import WATCH_PIN_TTL_HOURS
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/watch", tags=["watch"])
class WatchPinCreate(BaseModel):
server_id: int = Field(..., gt=0)
slot_index: int | None = Field(None, ge=0, le=3)
replace_slot: int | None = Field(None, ge=0, le=3, description="监测已满时指定替换槽位")
ttl_hours: Literal[8, 24] = Field(
WATCH_PIN_TTL_HOURS,
description="监测窗口时长,默认 8 小时;24 小时需显式指定",
)
class WatchPinReplace(BaseModel):
server_id: int = Field(..., gt=0)
class WatchPinMonitoringUpdate(BaseModel):
monitoring_enabled: bool = Field(..., description="是否开启此槽位实时监测")
class WatchPinTtlUpdate(BaseModel):
ttl_hours: Literal[8, 24] = Field(..., description="监测窗口时长:8 或 24 小时")
def _client_ip(request: Request) -> str:
return request.client.host if request.client else ""
@router.get("/pins")
async def list_watch_pins(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
return await svc.list_slots(admin.id)
@router.post("/pins")
async def create_watch_pin(
payload: WatchPinCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
try:
return await svc.pin_server(
admin=admin,
server_id=payload.server_id,
slot_index=payload.slot_index,
replace_slot=payload.replace_slot,
ttl_hours=payload.ttl_hours,
ip_address=_client_ip(request),
)
except SlotsFullError as e:
raise HTTPException(status_code=409, detail="监测槽已满,请选择要替换的槽位") from e
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
@router.put("/pins/{slot_index}")
async def replace_watch_pin(
slot_index: int,
payload: WatchPinReplace,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
try:
return await svc.replace_slot(
admin=admin,
slot_index=slot_index,
server_id=payload.server_id,
ip_address=_client_ip(request),
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
@router.delete("/pins/{slot_index}")
async def delete_watch_pin(
slot_index: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
try:
return await svc.remove_slot(
admin=admin,
slot_index=slot_index,
ip_address=_client_ip(request),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@router.patch("/pins/{slot_index}/monitoring")
async def update_watch_pin_monitoring(
slot_index: int,
payload: WatchPinMonitoringUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
try:
return await svc.set_slot_monitoring(
admin=admin,
slot_index=slot_index,
monitoring_enabled=payload.monitoring_enabled,
ip_address=_client_ip(request),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@router.patch("/pins/{slot_index}/ttl")
async def update_watch_pin_ttl(
slot_index: int,
payload: WatchPinTtlUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
try:
return await svc.set_slot_ttl(
admin=admin,
slot_index=slot_index,
ttl_hours=payload.ttl_hours,
ip_address=_client_ip(request),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@router.get("/metrics")
async def get_watch_metrics(
server_ids: str = Query(..., description="逗号分隔 server_id"),
hours: int = Query(24, ge=1, le=168),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
ids = _parse_server_ids(server_ids)
svc = WatchService(db)
return await svc.get_metrics(admin.id, server_ids=ids, hours=hours)
@router.get("/probe-records")
async def get_watch_probe_records(
server_id: int | None = None,
session_id: int | None = None,
probe_status: str | None = None,
hours: int = Query(24, ge=1, le=168),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
return await svc.get_probe_records(
admin.id,
server_id=server_id,
session_id=session_id,
probe_status=probe_status,
hours=hours,
page=page,
per_page=per_page,
)
@router.get("/sessions")
async def get_watch_sessions(
hours: int = Query(168, ge=1, le=168),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
return await svc.get_sessions(admin.id, hours=hours)
@router.get("/processes/{server_id}")
async def get_watch_processes(
server_id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
svc = WatchService(db)
pinned = await svc.pinned_server_map(admin.id)
if server_id not in pinned:
raise HTTPException(status_code=404, detail="该服务器不在当前监测槽中")
return await svc.get_processes(server_id)
def _parse_server_ids(server_ids: str) -> list[int]:
ids: list[int] = []
for part in server_ids.split(","):
part = part.strip()
if not part:
continue
try:
ids.append(int(part))
except ValueError:
raise HTTPException(status_code=422, detail="server_ids 格式无效") from None
if not ids:
raise HTTPException(status_code=422, detail="server_ids 不能为空")
return ids
@router.get("/live")
async def get_watch_live(
server_ids: str = Query(..., description="逗号分隔 server_id"),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
ids = _parse_server_ids(server_ids)
svc = WatchService(db)
return await svc.get_live_for_servers(ids)