"""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)