647ebcb673
默认 30 分钟,支持 30分/1时/2时/8时/24时;槽卡展示 CPU/内存/硬盘进度条与 1 分钟负载;API 改用 ttl_minutes。 Co-authored-by: Cursor <cursoragent@cursor.com>
259 lines
7.6 KiB
Python
259 lines
7.6 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_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)
|