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

446 lines
16 KiB
Python

"""Watch slot business logic — pin CRUD and live metrics assembly."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Admin, AuditLog, Server
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.watch_repo import WatchRepositoryImpl
from server.infrastructure.redis.client import get_redis
from server.utils.watch_state import clear_watch_redis_snapshot
from server.utils.watch_metrics import (
WATCH_MAX_SLOTS,
WATCH_PIN_TTL_HOURS,
normalize_watch_ttl_hours,
server_can_be_watched,
)
REDIS_WATCH_LIVE_PREFIX = "watch:live:"
REDIS_WATCH_PROC_PREFIX = "watch:proc:"
WATCH_SPARKLINE_MAX = 180 # 15 min @ 5s
def _utc_naive_now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _remaining_sec(expires_at: datetime) -> int:
now = _utc_naive_now()
return max(0, int((expires_at - now).total_seconds()))
async def _load_live_metrics(server_id: int) -> dict[str, Any] | None:
redis = get_redis()
raw = await redis.lindex(f"{REDIS_WATCH_LIVE_PREFIX}{server_id}", 0)
if not raw:
return None
try:
data = json.loads(raw)
return data if isinstance(data, dict) else None
except json.JSONDecodeError:
return None
async def _load_sparkline(server_id: int) -> list[dict[str, Any]]:
redis = get_redis()
rows = await redis.lrange(f"{REDIS_WATCH_LIVE_PREFIX}{server_id}", 0, WATCH_SPARKLINE_MAX - 1)
out: list[dict[str, Any]] = []
for raw in reversed(rows):
try:
point = json.loads(raw)
if isinstance(point, dict):
out.append(point)
except json.JSONDecodeError:
continue
return out
async def _load_processes(server_id: int) -> list[dict[str, Any]] | None:
redis = get_redis()
raw = await redis.get(f"{REDIS_WATCH_PROC_PREFIX}{server_id}")
if not raw:
return None
try:
data = json.loads(raw)
return data if isinstance(data, list) else None
except json.JSONDecodeError:
return None
async def _restore_idle_agent_watch(watch_repo: WatchRepositoryImpl, server_id: int) -> None:
"""Stop 5s watch snapshots when no slot actively monitors this server (Agent stays 60s)."""
if await watch_repo.count_monitoring_pins_for_server(server_id) == 0:
await clear_watch_redis_snapshot(server_id)
async def _finalize_due_pins(watch_repo: WatchRepositoryImpl, db: AsyncSession) -> None:
"""Pause TTL-expired pins on read/write paths (probe loop also runs this every 5s)."""
expired_servers = await watch_repo.expire_due_pins()
if not expired_servers:
return
for server_id in expired_servers:
if await watch_repo.count_monitoring_pins_for_server(server_id) == 0:
await clear_watch_redis_snapshot(server_id)
await db.commit()
async def _slot_payload(
*,
slot_index: int,
pin_row: dict[str, Any] | None,
) -> dict[str, Any]:
if not pin_row:
return {"slot_index": slot_index, "empty": True}
monitoring_enabled = pin_row.get("monitoring_enabled", True)
metrics = None
sparkline: list[dict[str, Any]] = []
processes = None
if monitoring_enabled:
metrics = await _load_live_metrics(pin_row["server_id"])
sparkline = await _load_sparkline(pin_row["server_id"])
processes = await _load_processes(pin_row["server_id"])
if metrics and processes:
metrics = {**metrics, "processes": processes}
remaining = _remaining_sec(pin_row["expires_at"]) if monitoring_enabled else None
return {
"slot_index": slot_index,
"empty": False,
"pin_id": pin_row["pin_id"],
"server_id": pin_row["server_id"],
"server_name": pin_row.get("server_name") or "",
"session_id": pin_row["session_id"],
"pinned_at": pin_row["pinned_at"].isoformat() if pin_row.get("pinned_at") else None,
"expires_at": pin_row["expires_at"].isoformat() if pin_row.get("expires_at") else None,
"remaining_sec": remaining,
"monitoring_enabled": monitoring_enabled,
"ttl_hours": int(pin_row.get("ttl_hours") or WATCH_PIN_TTL_HOURS),
"metrics": metrics,
"sparkline": sparkline,
"processes": processes,
}
class WatchService:
def __init__(self, db: AsyncSession):
self.db = db
self.watch_repo = WatchRepositoryImpl(db)
self.server_repo = ServerRepositoryImpl(db)
self.audit_repo = AuditLogRepositoryImpl(db)
async def list_slots(self, admin_id: int) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
pins = await self.watch_repo.list_active_pins_for_admin(admin_id)
by_slot = {p["slot_index"]: p for p in pins}
slots = [
await _slot_payload(slot_index=i, pin_row=by_slot.get(i))
for i in range(WATCH_MAX_SLOTS)
]
return {"slots": slots}
async def pin_server(
self,
*,
admin: Admin,
server_id: int,
slot_index: int | None = None,
replace_slot: int | None = None,
ttl_hours: int = WATCH_PIN_TTL_HOURS,
ip_address: str = "",
) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
hours = normalize_watch_ttl_hours(ttl_hours)
server = await self.server_repo.get_by_id(server_id)
if not server:
raise ValueError("服务器不存在")
ok, reason = server_can_be_watched(server)
if not ok:
raise ValueError(reason)
existing = await self.watch_repo.get_pin_by_server(admin.id, server_id)
if existing:
await self.watch_repo.reset_pin_ttl(admin.id, server_id, hours)
await self._audit(
admin=admin,
action="watch_pin_add",
server=server,
detail=f"重置监测 {hours}h · 槽 {existing.slot_index + 1}",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
if slot_index is not None:
await self.watch_repo.replace_pin_server(
admin_id=admin.id,
slot_index=slot_index,
server_id=server_id,
ttl_hours=hours,
)
await self._audit(
admin=admin,
action="watch_pin_replace" if replace_slot is not None else "watch_pin_add",
server=server,
detail=f"加入监测 · 槽 {slot_index + 1} · {hours}h",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
empty = await self.watch_repo.first_empty_slot(admin.id)
if empty is not None:
await self.watch_repo.create_pin(
admin_id=admin.id,
server_id=server_id,
slot_index=empty,
ttl_hours=hours,
)
await self._audit(
admin=admin,
action="watch_pin_add",
server=server,
detail=f"加入监测 · 槽 {empty + 1} · {hours}h",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
if replace_slot is not None:
await self.watch_repo.replace_pin_server(
admin_id=admin.id,
slot_index=replace_slot,
server_id=server_id,
ttl_hours=hours,
)
await self._audit(
admin=admin,
action="watch_pin_replace",
server=server,
detail=f"替换监测 · 槽 {replace_slot + 1} · {hours}h",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
raise SlotsFullError()
async def replace_slot(
self,
*,
admin: Admin,
slot_index: int,
server_id: int,
ip_address: str = "",
) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
raise ValueError("槽位无效")
server = await self.server_repo.get_by_id(server_id)
if not server:
raise ValueError("服务器不存在")
ok, reason = server_can_be_watched(server)
if not ok:
raise ValueError(reason)
other = await self.watch_repo.get_pin_by_server(admin.id, server_id)
if other and other.slot_index != slot_index:
await self.watch_repo.end_session(other.session_id, "replaced")
await self.watch_repo.delete_pin(other.id)
existing = await self.watch_repo.get_pin_by_slot(admin.id, slot_index)
slot_ttl = normalize_watch_ttl_hours(int(existing.ttl_hours or 8)) if existing else WATCH_PIN_TTL_HOURS
await self.watch_repo.replace_pin_server(
admin_id=admin.id,
slot_index=slot_index,
server_id=server_id,
ttl_hours=slot_ttl,
)
await self._audit(
admin=admin,
action="watch_pin_replace",
server=server,
detail=f"替换监测 · 槽 {slot_index + 1} · {slot_ttl}h",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
async def remove_slot(
self,
*,
admin: Admin,
slot_index: int,
ip_address: str = "",
) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
raise ValueError("槽位无效")
pin = await self.watch_repo.remove_pin_by_slot(admin.id, slot_index)
if not pin:
raise ValueError("槽位为空")
server_id = pin.server_id
server = await self.server_repo.get_by_id(server_id)
await self._audit(
admin=admin,
action="watch_pin_remove",
server=server,
detail=f"移除监测 · 槽 {slot_index + 1}",
ip_address=ip_address,
)
await self.db.commit()
await _restore_idle_agent_watch(self.watch_repo, server_id)
return await self.list_slots(admin.id)
async def set_slot_monitoring(
self,
*,
admin: Admin,
slot_index: int,
monitoring_enabled: bool,
ip_address: str = "",
) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
raise ValueError("槽位无效")
pin = await self.watch_repo.set_monitoring_enabled(
admin.id,
slot_index,
monitoring_enabled,
)
if not pin:
raise ValueError("槽位为空")
server = await self.server_repo.get_by_id(pin.server_id)
hours = int(pin.ttl_hours or WATCH_PIN_TTL_HOURS)
action = "watch_pin_resume" if monitoring_enabled else "watch_pin_pause"
detail = (
f"开启实时监测 · 槽 {slot_index + 1} · 重置 {hours}h"
if monitoring_enabled
else f"暂停实时监测 · 槽 {slot_index + 1} · 恢复 Agent 60s"
)
await self._audit(
admin=admin,
action=action,
server=server,
detail=detail,
ip_address=ip_address,
)
await self.db.commit()
if not monitoring_enabled:
await _restore_idle_agent_watch(self.watch_repo, pin.server_id)
return await self.list_slots(admin.id)
async def set_slot_ttl(
self,
*,
admin: Admin,
slot_index: int,
ttl_hours: int,
ip_address: str = "",
) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db)
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
raise ValueError("槽位无效")
hours = normalize_watch_ttl_hours(ttl_hours)
pin = await self.watch_repo.set_pin_ttl_hours(admin.id, slot_index, hours)
if not pin:
raise ValueError("槽位为空")
server = await self.server_repo.get_by_id(pin.server_id)
await self._audit(
admin=admin,
action="watch_pin_ttl",
server=server,
detail=f"监测时长 · 槽 {slot_index + 1} · {hours}h",
ip_address=ip_address,
)
await self.db.commit()
return await self.list_slots(admin.id)
async def get_live_for_servers(self, server_ids: list[int]) -> dict[str, Any]:
items: list[dict[str, Any]] = []
for sid in server_ids:
metrics = await _load_live_metrics(sid)
sparkline = await _load_sparkline(sid)
items.append({"server_id": sid, "metrics": metrics, "sparkline": sparkline})
return {"items": items}
async def pinned_server_map(self, admin_id: int) -> dict[int, int]:
"""server_id → slot_index (0-based) for list UI."""
pins = await self.watch_repo.list_active_pins_for_admin(admin_id)
return {p["server_id"]: p["slot_index"] for p in pins}
async def get_metrics(
self,
admin_id: int,
*,
server_ids: list[int],
hours: int = 24,
) -> dict[str, Any]:
since = _utc_naive_now() - timedelta(hours=max(1, min(hours, 168)))
points = await self.watch_repo.list_metrics_series(
admin_id,
server_ids=server_ids,
since=since,
ok_only=True,
)
return {"points": points, "hours": hours}
async def get_probe_records(
self,
admin_id: int,
*,
server_id: int | None = None,
session_id: int | None = None,
probe_status: str | None = None,
hours: int = 24,
page: int = 1,
per_page: int = 50,
) -> dict[str, Any]:
since = _utc_naive_now() - timedelta(hours=max(1, min(hours, 168)))
items, total = await self.watch_repo.list_probe_records(
admin_id,
server_id=server_id,
session_id=session_id,
probe_status=probe_status,
since=since,
page=page,
per_page=per_page,
)
return {"items": items, "total": total, "page": page, "per_page": per_page}
async def get_sessions(self, admin_id: int, *, hours: int = 168) -> dict[str, Any]:
since = _utc_naive_now() - timedelta(hours=max(1, min(hours, 168)))
sessions = await self.watch_repo.list_sessions(admin_id, since=since)
return {"sessions": sessions, "hours": hours}
async def get_processes(self, server_id: int) -> dict[str, Any]:
processes = await _load_processes(server_id)
return {"server_id": server_id, "processes": processes or []}
async def _audit(
self,
*,
admin: Admin,
action: str,
server: Server | None,
detail: str,
ip_address: str,
) -> None:
await self.audit_repo.create(
AuditLog(
admin_username=admin.username,
action=action,
target_type="server",
target_id=server.id if server else 0,
detail=detail,
ip_address=ip_address,
)
)
class SlotsFullError(Exception):
"""All 4 watch slots occupied."""