Files
Nexus/server/api/watch.py
T

259 lines
7.6 KiB
Python
Raw Normal View History

"""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_DEFAULT_MINUTES
WatchTtlMinutes = Literal[30, 60, 120, 480, 1440]
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_minutes: WatchTtlMinutes = Field(
WATCH_PIN_TTL_DEFAULT_MINUTES,
description="监测窗口:30分/1h/2h/8h/24h,默认 30 分钟",
)
class WatchPinReplace(BaseModel):
server_id: int = Field(..., gt=0)
class WatchPinMonitoringUpdate(BaseModel):
monitoring_enabled: bool = Field(..., description="是否开启此槽位实时监测")
class WatchPinTtlUpdate(BaseModel):
ttl_minutes: WatchTtlMinutes = Field(..., description="监测窗口:30分/1h/2h/8h/24h")
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_minutes=payload.ttl_minutes,
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_minutes=payload.ttl_minutes,
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)
@router.post("/servers/{server_id}/install-psutil")
async def install_watch_psutil(
server_id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Install psutil on the pinned server via SSH (for watch probe metrics)."""
svc = WatchService(db)
try:
return await svc.install_psutil_ssh(
admin=admin,
server_id=server_id,
ip_address=_client_ip(request),
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
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)