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

303 lines
12 KiB
Python

"""Background watch probe loop — 5s sampling for pinned servers."""
from __future__ import annotations
import asyncio
import json
import logging
import time
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any
from server.domain.models import Server
from server.infrastructure.database.session import AsyncSessionLocal
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.infrastructure.ssh.remote_probe import ssh_watch_probe, ssh_watch_processes
from server.utils.watch_alerts import process_watch_probe_alerts
from server.utils.watch_probe_errors import watch_probe_error_zh
from server.utils.watch_state import clear_watch_redis_snapshot
from server.utils.watch_metrics import (
WATCH_PROBE_INTERVAL_SEC,
WATCH_PROBE_TIMEOUT_SEC,
WATCH_REDIS_FRESH_SEC,
WatchProbeSample,
_RateState,
apply_rates_to_sample,
failure_sample,
merge_redis_and_ssh,
parse_redis_watch_payload,
parse_ssh_watch_payload,
redis_sample_has_io,
redis_sample_has_basic_metrics,
)
logger = logging.getLogger("nexus.watch_probe")
REDIS_HEARTBEAT_PREFIX = "heartbeat:"
REDIS_WATCH_LAST_PREFIX = "watch:last:"
REDIS_WATCH_LIVE_PREFIX = "watch:live:"
REDIS_WATCH_PROC_PREFIX = "watch:proc:"
WATCH_LIVE_MAX_POINTS = 360
WATCH_RECORDS_RETENTION_DAYS = 7
_process_cycle: dict[int, int] = defaultdict(int)
def _utc_naive_now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _heartbeat_fresh(heartbeat: dict[str, str]) -> bool:
if not heartbeat or heartbeat.get("is_online") != "True":
return False
ts = heartbeat.get("last_heartbeat") or ""
if not ts:
return False
try:
agent_ts = datetime.fromisoformat(ts.replace("Z", "+00:00"))
if agent_ts.tzinfo is None:
agent_ts = agent_ts.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - agent_ts).total_seconds()
return age <= WATCH_REDIS_FRESH_SEC
except Exception:
return False
async def _load_rate_state(redis, server_id: int) -> _RateState | None:
raw = await redis.get(f"{REDIS_WATCH_LAST_PREFIX}{server_id}")
if not raw:
return None
try:
data = json.loads(raw)
return _RateState(
net_bytes_sent=data.get("net_bytes_sent"),
net_bytes_recv=data.get("net_bytes_recv"),
disk_read_bytes=data.get("disk_read_bytes"),
disk_write_bytes=data.get("disk_write_bytes"),
recorded_at_ts=data.get("recorded_at_ts"),
)
except (json.JSONDecodeError, TypeError):
return None
async def _save_rate_state(redis, server_id: int, state: _RateState) -> None:
await redis.set(
f"{REDIS_WATCH_LAST_PREFIX}{server_id}",
json.dumps(
{
"net_bytes_sent": state.net_bytes_sent,
"net_bytes_recv": state.net_bytes_recv,
"disk_read_bytes": state.disk_read_bytes,
"disk_write_bytes": state.disk_write_bytes,
"recorded_at_ts": state.recorded_at_ts,
}
),
ex=600,
)
async def _append_live_point(redis, server_id: int, point: dict[str, Any]) -> None:
key = f"{REDIS_WATCH_LIVE_PREFIX}{server_id}"
await redis.lpush(key, json.dumps(point))
await redis.ltrim(key, 0, WATCH_LIVE_MAX_POINTS - 1)
await redis.expire(key, 3600)
async def _save_processes(redis, server_id: int, processes: list[dict[str, Any]] | None) -> None:
if not processes:
return
await redis.set(f"{REDIS_WATCH_PROC_PREFIX}{server_id}", json.dumps(processes), ex=60)
async def probe_server_metrics(
server: Server,
heartbeat: dict[str, str] | None,
*,
fetch_processes: bool = False,
) -> WatchProbeSample:
redis_sample: WatchProbeSample | None = None
if heartbeat and _heartbeat_fresh(heartbeat):
redis_sample = parse_redis_watch_payload(heartbeat)
if redis_sample is not None and redis_sample_has_io(redis_sample):
sample = redis_sample
elif redis_sample is not None and redis_sample_has_basic_metrics(redis_sample):
# Agent CPU/mem available — SSH only supplements IO; SSH failure must not block display
ssh_result = await ssh_watch_probe(server, timeout=WATCH_PROBE_TIMEOUT_SEC)
if ssh_result.get("ok"):
ssh_sample = parse_ssh_watch_payload(ssh_result["payload"])
ssh_sample.duration_ms = int(ssh_result.get("duration_ms") or 0)
sample = merge_redis_and_ssh(redis_sample, ssh_sample)
else:
sample = redis_sample
else:
ssh_result = await ssh_watch_probe(server, timeout=WATCH_PROBE_TIMEOUT_SEC)
if ssh_result.get("ok"):
ssh_sample = parse_ssh_watch_payload(ssh_result["payload"])
ssh_sample.duration_ms = int(ssh_result.get("duration_ms") or 0)
if redis_sample is not None:
sample = merge_redis_and_ssh(redis_sample, ssh_sample)
else:
sample = ssh_sample
elif redis_sample is not None:
redis_sample.duration_ms = int(ssh_result.get("duration_ms") or 0)
sample = redis_sample
else:
status = ssh_result.get("probe_status") or "offline"
if not heartbeat and status == "offline":
return failure_sample(
status="no_cred",
source="ssh",
error=watch_probe_error_zh(
ssh_result.get("error") or "无 Agent 且 SSH 探针失败",
status="no_cred",
) or "无 Agent 且 SSH 探针失败",
duration_ms=int(ssh_result.get("duration_ms") or 0),
)
return failure_sample(
status=status,
source="ssh",
error=watch_probe_error_zh(ssh_result.get("error") or "探针失败", status=status) or "探针失败",
duration_ms=int(ssh_result.get("duration_ms") or 0),
)
if fetch_processes and sample.probe_status == "ok":
procs = await ssh_watch_processes(server, timeout=WATCH_PROBE_TIMEOUT_SEC)
if procs:
sample.processes_json = procs
return sample
async def _run_probe_cycle() -> None:
async with AsyncSessionLocal() as db:
watch_repo = WatchRepositoryImpl(db)
server_repo = ServerRepositoryImpl(db)
expired_servers = await watch_repo.expire_due_pins()
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)
pins = await watch_repo.list_active_pins_all(monitoring_only=True)
if not pins:
await db.commit()
return
by_server: dict[int, list[dict[str, Any]]] = defaultdict(list)
for p in pins:
by_server[p["server_id"]].append(p)
redis = get_redis()
now = _utc_naive_now()
now_ts = time.time()
for server_id, pin_rows in by_server.items():
server = await server_repo.get_by_id(server_id)
if not server:
sample = failure_sample(status="offline", source="ssh", error="服务器不存在")
server_name = f"server-{server_id}"
else:
server_name = server.name or f"server-{server_id}"
heartbeat = await redis.hgetall(f"{REDIS_HEARTBEAT_PREFIX}{server_id}")
_process_cycle[server_id] += 1
fetch_procs = _process_cycle[server_id] % 2 == 0
sample = await probe_server_metrics(
server,
heartbeat or None,
fetch_processes=fetch_procs,
)
prev = await _load_rate_state(redis, server_id)
new_state = apply_rates_to_sample(sample, prev, now_ts)
await _save_rate_state(redis, server_id, new_state)
live = sample.to_live_dict(server_id=server_id)
live["ts"] = now.isoformat()
await _append_live_point(redis, server_id, live)
if sample.processes_json:
await _save_processes(redis, server_id, sample.processes_json)
if sample.probe_status == "ok" and server:
try:
await process_watch_probe_alerts(
server_id=server_id,
server_name=server_name,
sample=sample,
)
except Exception:
logger.debug("watch alert processing failed", exc_info=True)
admin_ids = [r["admin_id"] for r in pin_rows]
try:
from server.api.websocket import publish_watch_metrics
await publish_watch_metrics(
{
"type": "watch_metrics",
"server_id": server_id,
"admin_ids": admin_ids,
"metrics": live,
}
)
except Exception:
logger.debug("watch WS publish failed", exc_info=True)
for row in pin_rows:
await watch_repo.insert_probe_record(
session_id=row["session_id"],
server_id=server_id,
admin_id=row["admin_id"],
recorded_at=now,
source=sample.source,
probe_status=sample.probe_status,
duration_ms=sample.duration_ms,
is_online=sample.is_online,
cpu_pct=sample.cpu_pct,
mem_pct=sample.mem_pct,
disk_pct=sample.disk_pct,
disk_mount=sample.disk_mount,
load_1=sample.load_1,
load_5=sample.load_5,
load_15=sample.load_15,
net_bytes_sent=sample.net_bytes_sent,
net_bytes_recv=sample.net_bytes_recv,
disk_read_bytes=sample.disk_read_bytes,
disk_write_bytes=sample.disk_write_bytes,
net_up_bps=sample.net_up_bps,
net_down_bps=sample.net_down_bps,
disk_read_bps=sample.disk_read_bps,
disk_write_bps=sample.disk_write_bps,
processes_json=sample.processes_json,
error=sample.error,
)
await db.commit()
async def _purge_old_records() -> None:
cutoff = _utc_naive_now() - timedelta(days=WATCH_RECORDS_RETENTION_DAYS)
async with AsyncSessionLocal() as db:
repo = WatchRepositoryImpl(db)
deleted = await repo.purge_records_before(cutoff)
await db.commit()
if deleted:
logger.info("Purged %s watch_probe_records older than %s days", deleted, WATCH_RECORDS_RETENTION_DAYS)
async def watch_probe_loop() -> None:
"""Run watch probes every 5 seconds (primary worker only)."""
purge_counter = 0
while True:
try:
await _run_probe_cycle()
purge_counter += 1
if purge_counter >= 720: # ~1h at 5s
purge_counter = 0
await _purge_old_records()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("watch_probe cycle failed")
await asyncio.sleep(WATCH_PROBE_INTERVAL_SEC)