release: nexus btpanel session fix and app-v2
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""Nexus — Background Tasks Package
|
||||
"""
|
||||
|
||||
from server.background.heartbeat_flush import heartbeat_flush_loop
|
||||
from server.background.self_monitor import self_monitor_loop
|
||||
|
||||
__all__ = ["heartbeat_flush_loop", "self_monitor_loop"]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Nightly cron: auto-install Agent on servers without agent_version."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from server.application.services.agent_install_schedule import (
|
||||
_cron_expr,
|
||||
_enabled,
|
||||
run_scheduled_agent_install,
|
||||
)
|
||||
from server.background.schedule_runner import _cron_match
|
||||
|
||||
logger = logging.getLogger("nexus.agent_install_loop")
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
async def agent_install_loop() -> None:
|
||||
"""Primary worker: fire scheduled install-agent when cron matches (Beijing wall clock)."""
|
||||
while True:
|
||||
try:
|
||||
if _enabled():
|
||||
now = datetime.now(timezone.utc)
|
||||
if _cron_match(_cron_expr(), now):
|
||||
result = await run_scheduled_agent_install()
|
||||
if result.get("action") == "started":
|
||||
logger.info(
|
||||
"Agent install schedule: job_id=%s servers=%s",
|
||||
result.get("job_id"),
|
||||
result.get("server_count"),
|
||||
)
|
||||
elif result.get("reason") not in ("already_ran_today",):
|
||||
logger.debug("Agent install schedule: %s", result)
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Agent install schedule loop error")
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Periodic purge of alert_logs older than ALERT_LOG_RETENTION_DAYS (7 days)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from server.infrastructure.database.alert_log_repo import (
|
||||
ALERT_LOG_RETENTION_DAYS,
|
||||
AlertLogRepositoryImpl,
|
||||
)
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger("nexus.alert_log_purge")
|
||||
|
||||
PURGE_INTERVAL_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
async def purge_stale_alert_logs() -> int:
|
||||
"""Delete alert_logs rows older than retention window. Returns rows removed."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = AlertLogRepositoryImpl(session)
|
||||
return await repo.purge_older_than(retention_days=ALERT_LOG_RETENTION_DAYS)
|
||||
|
||||
|
||||
async def alert_log_purge_loop() -> None:
|
||||
"""Run daily on primary worker; first purge runs immediately at startup."""
|
||||
while True:
|
||||
try:
|
||||
count = await purge_stale_alert_logs()
|
||||
if count:
|
||||
logger.info(
|
||||
"Alert log purge: removed %s row(s) older than %s days",
|
||||
count,
|
||||
ALERT_LOG_RETENTION_DAYS,
|
||||
)
|
||||
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Alert log purge loop error")
|
||||
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Periodic purge of audit_logs older than AUDIT_LOG_RETENTION_DAYS (7 days)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from server.infrastructure.database.audit_log_repo import (
|
||||
AUDIT_LOG_RETENTION_DAYS,
|
||||
AuditLogRepositoryImpl,
|
||||
)
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger("nexus.audit_log_purge")
|
||||
|
||||
PURGE_INTERVAL_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
async def purge_stale_audit_logs() -> int:
|
||||
"""Delete audit_logs rows older than retention window. Returns rows removed."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = AuditLogRepositoryImpl(session)
|
||||
return await repo.purge_older_than(retention_days=AUDIT_LOG_RETENTION_DAYS)
|
||||
|
||||
|
||||
async def audit_log_purge_loop() -> None:
|
||||
"""Run daily on primary worker; first purge runs immediately at startup."""
|
||||
while True:
|
||||
try:
|
||||
count = await purge_stale_audit_logs()
|
||||
if count:
|
||||
logger.info(
|
||||
"Audit log purge: removed %s row(s) older than %s days",
|
||||
count,
|
||||
AUDIT_LOG_RETENTION_DAYS,
|
||||
)
|
||||
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Audit log purge loop error")
|
||||
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Primary-worker loop: retry Baota SSH API bootstrap every 5 minutes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from server.application.services.btpanel_bootstrap_schedule import run_bootstrap_tick
|
||||
from server.config import settings
|
||||
|
||||
logger = logging.getLogger("nexus.bt_panel_bootstrap_loop")
|
||||
|
||||
|
||||
def _interval_seconds() -> int:
|
||||
raw = getattr(settings, "BT_PANEL_BOOTSTRAP_INTERVAL", 300)
|
||||
try:
|
||||
n = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
n = 300
|
||||
return max(60, n)
|
||||
|
||||
|
||||
async def bt_panel_bootstrap_loop() -> None:
|
||||
interval = _interval_seconds()
|
||||
logger.info("bt_panel_bootstrap_loop started interval=%ss", interval)
|
||||
while True:
|
||||
try:
|
||||
count = await run_bootstrap_tick()
|
||||
if count:
|
||||
logger.info("bt_panel_bootstrap_loop succeeded=%s", count)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("bt_panel_bootstrap_loop tick failed")
|
||||
await asyncio.sleep(interval)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Nexus — Heartbeat Flush Background Task
|
||||
Every 10 minutes: batch-flush Redis heartbeat data to MySQL servers table.
|
||||
MySQL only stores historical snapshots; frontend reads live data from Redis.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
from server.domain.models import Server
|
||||
from server.infrastructure.database.fleet_metric_repo import FleetMetricRepositoryImpl
|
||||
from server.infrastructure.database.server_metric_repo import ServerMetricRepositoryImpl
|
||||
from server.utils.fleet_metrics import aggregate_fleet_metrics
|
||||
from server.utils.server_metrics import sample_from_redis_heartbeat
|
||||
|
||||
logger = logging.getLogger("nexus.heartbeat_flush")
|
||||
|
||||
FLUSH_INTERVAL = 600 # 10 minutes (seconds)
|
||||
REDIS_KEY_PREFIX = "heartbeat:"
|
||||
# Redis TTL for live keys: server.api.agent.REDIS_KEY_EXPIRE (= FLUSH_INTERVAL * 1.5)
|
||||
|
||||
|
||||
async def _write_server_metric_samples(
|
||||
session: AsyncSession,
|
||||
redis_by_id: dict[int, dict],
|
||||
recorded_at: datetime,
|
||||
) -> int:
|
||||
"""Insert per-server CPU/mem samples for all agent-installed servers."""
|
||||
result = await session.execute(
|
||||
select(Server.id).where(
|
||||
Server.agent_version.isnot(None),
|
||||
Server.agent_version != "",
|
||||
)
|
||||
)
|
||||
agent_ids = [row[0] for row in result.all()]
|
||||
if not agent_ids:
|
||||
return 0
|
||||
|
||||
repo = ServerMetricRepositoryImpl(session)
|
||||
written = 0
|
||||
for server_id in agent_ids:
|
||||
data = redis_by_id.get(server_id)
|
||||
if data:
|
||||
is_online, cpu_pct, mem_pct = sample_from_redis_heartbeat(data)
|
||||
else:
|
||||
is_online, cpu_pct, mem_pct = False, None, None
|
||||
await repo.insert_sample(
|
||||
server_id=server_id,
|
||||
recorded_at=recorded_at,
|
||||
is_online=is_online,
|
||||
cpu_pct=cpu_pct,
|
||||
mem_pct=mem_pct,
|
||||
)
|
||||
written += 1
|
||||
return written
|
||||
|
||||
|
||||
async def _flush_redis_keys(session: AsyncSession, keys: list[str], redis) -> tuple[int, int, list[dict], dict[int, dict]]:
|
||||
"""Update servers from Redis keys; return fleet rows and id→heartbeat map."""
|
||||
flushed = 0
|
||||
skipped = 0
|
||||
heartbeat_rows: list[dict] = []
|
||||
redis_by_id: dict[int, dict] = {}
|
||||
|
||||
for key in keys:
|
||||
try:
|
||||
server_id = int(key.replace(REDIS_KEY_PREFIX, ""))
|
||||
data = await redis.hgetall(key)
|
||||
|
||||
if not data:
|
||||
continue
|
||||
|
||||
row = dict(data)
|
||||
heartbeat_rows.append(row)
|
||||
redis_by_id[server_id] = row
|
||||
is_online = data.get("is_online") == "True"
|
||||
system_info = data.get("system_info", "{}")
|
||||
agent_version = data.get("agent_version", "")
|
||||
|
||||
last_hb_str = data.get("last_heartbeat", "")
|
||||
try:
|
||||
last_heartbeat = datetime.fromisoformat(last_hb_str)
|
||||
if last_heartbeat.tzinfo is None:
|
||||
last_heartbeat = last_heartbeat.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
last_heartbeat = datetime.now(timezone.utc)
|
||||
|
||||
result = await session.execute(
|
||||
update(Server)
|
||||
.where(Server.id == server_id)
|
||||
.values(
|
||||
is_online=is_online,
|
||||
system_info=system_info,
|
||||
last_heartbeat=last_heartbeat,
|
||||
agent_version=agent_version,
|
||||
)
|
||||
)
|
||||
if (result.rowcount or 0) > 0:
|
||||
flushed += 1
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to flush server {key}: {e}")
|
||||
skipped += 1
|
||||
|
||||
return flushed, skipped, heartbeat_rows, redis_by_id
|
||||
|
||||
|
||||
async def _commit_flush_metrics(
|
||||
session: AsyncSession,
|
||||
heartbeat_rows: list[dict],
|
||||
redis_by_id: dict[int, dict],
|
||||
recorded_at: datetime,
|
||||
) -> None:
|
||||
fleet_repo = FleetMetricRepositoryImpl(session)
|
||||
if heartbeat_rows:
|
||||
agg = aggregate_fleet_metrics(heartbeat_rows)
|
||||
await fleet_repo.insert_sample(
|
||||
recorded_at=recorded_at,
|
||||
online_count=agg.online_count,
|
||||
sample_count=agg.sample_count,
|
||||
cpu_avg=agg.cpu_avg,
|
||||
mem_avg=agg.mem_avg,
|
||||
disk_avg=agg.disk_avg,
|
||||
)
|
||||
purged_fleet = await fleet_repo.purge_older_than()
|
||||
|
||||
server_samples = await _write_server_metric_samples(session, redis_by_id, recorded_at)
|
||||
server_repo = ServerMetricRepositoryImpl(session)
|
||||
purged_server = await server_repo.purge_older_than()
|
||||
|
||||
await session.commit()
|
||||
if purged_fleet:
|
||||
logger.info("Fleet metric purge: removed %s rows older than retention", purged_fleet)
|
||||
if purged_server:
|
||||
logger.info("Server metric purge: removed %s rows older than retention", purged_server)
|
||||
if server_samples:
|
||||
logger.info("Server metric samples: wrote %s rows", server_samples)
|
||||
|
||||
|
||||
async def heartbeat_flush_loop():
|
||||
"""Background task: every 10min, flush Redis heartbeat data to MySQL.
|
||||
|
||||
Flow:
|
||||
1. Scan all heartbeat:* keys in Redis
|
||||
2. For each key, extract server_id + heartbeat data
|
||||
3. Batch UPDATE MySQL servers table (is_online, system_info, last_heartbeat, agent_version)
|
||||
4. Write fleet + per-server metric samples; purge retention
|
||||
"""
|
||||
logger.info("Heartbeat flush loop started (interval: 10min)")
|
||||
while True:
|
||||
await asyncio.sleep(FLUSH_INTERVAL)
|
||||
|
||||
redis = get_redis()
|
||||
recorded_at = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
keys: list[str] = []
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, batch = await redis.scan(cursor, match=f"{REDIS_KEY_PREFIX}*", count=100)
|
||||
keys.extend(batch)
|
||||
if cursor == 0:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Redis scan failed: {e}")
|
||||
continue
|
||||
|
||||
if not keys:
|
||||
logger.info("No heartbeat keys in Redis — writing offline server metric samples only")
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
await _commit_flush_metrics(session, [], {}, recorded_at)
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat flush commit failed (no Redis keys): {e}")
|
||||
await session.rollback()
|
||||
continue
|
||||
|
||||
flushed = 0
|
||||
skipped = 0
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
flushed, skipped, heartbeat_rows, redis_by_id = await _flush_redis_keys(
|
||||
session, keys, redis
|
||||
)
|
||||
await _commit_flush_metrics(session, heartbeat_rows, redis_by_id, recorded_at)
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat flush commit failed: {e}")
|
||||
await session.rollback()
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"Heartbeat flush: {flushed}/{len(keys)} servers updated to MySQL"
|
||||
+ (f", {skipped} failed" if skipped else "")
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Background task: refresh login subscription IPs every 2 hours.
|
||||
|
||||
Data model:
|
||||
login_subscription_url — URL to fetch (configurable in settings)
|
||||
login_subscription_ips — IPs from last fetch (completely replaced each time)
|
||||
login_manual_ips — manually added (never touched by auto-refresh)
|
||||
login_allowed_ips — combined: subscription + manual (used by auth check)
|
||||
login_allowlist_enabled — "true" / "false" master switch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger("nexus.ip_allowlist_refresh")
|
||||
|
||||
REFRESH_INTERVAL = 7200 # 2 hours
|
||||
_last_refresh_at: str = ""
|
||||
_last_subscription_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RefreshResult:
|
||||
ok: bool
|
||||
subscription_ips: tuple[str, ...] = ()
|
||||
last_refresh: str = ""
|
||||
subscription_count: int = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
async def ip_allowlist_refresh_loop():
|
||||
"""Every 2 hours, re-fetch subscription IPs and rebuild combined allowlist."""
|
||||
logger.info("IP allowlist refresh loop started (interval=%sh)", REFRESH_INTERVAL // 3600)
|
||||
# Initial refresh shortly after startup
|
||||
await asyncio.sleep(5)
|
||||
await do_refresh()
|
||||
while True:
|
||||
await asyncio.sleep(REFRESH_INTERVAL)
|
||||
await do_refresh()
|
||||
|
||||
|
||||
async def do_refresh() -> RefreshResult:
|
||||
"""Fetch subscription, replace subscription IPs, rebuild combined list."""
|
||||
return await _do_refresh()
|
||||
|
||||
|
||||
async def _do_refresh() -> RefreshResult:
|
||||
"""Fetch subscription, replace subscription IPs, rebuild combined list."""
|
||||
global _last_refresh_at, _last_subscription_count
|
||||
from server.config import settings
|
||||
from server.infrastructure.subscription_parser import parse_subscription_content
|
||||
|
||||
sub_url = (settings.LOGIN_SUBSCRIPTION_URL or "").strip()
|
||||
if not sub_url:
|
||||
return RefreshResult(ok=True)
|
||||
|
||||
logger.info("Refreshing subscription IPs from: %s", sub_url[:80])
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
resp = await client.get(sub_url, follow_redirects=True)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("Subscription returned HTTP %s", resp.status_code)
|
||||
return RefreshResult(ok=False, error=f"订阅链接返回 HTTP {resp.status_code}")
|
||||
new_sub_ips = parse_subscription_content(resp.text)
|
||||
except Exception as e:
|
||||
logger.error("Subscription fetch failed: %s", e)
|
||||
return RefreshResult(ok=False, error=f"拉取订阅失败: {e}")
|
||||
|
||||
# Manual IPs (always preserved)
|
||||
manual_raw = (settings.LOGIN_MANUAL_IPS or "").strip()
|
||||
manual_ips = [ip.strip() for ip in manual_raw.split(",") if ip.strip()]
|
||||
|
||||
# Combined list: subscription first, then manual (dedup)
|
||||
seen: set[str] = set()
|
||||
combined: list[str] = []
|
||||
for ip in new_sub_ips + manual_ips:
|
||||
if ip not in seen:
|
||||
seen.add(ip)
|
||||
combined.append(ip)
|
||||
|
||||
sub_val = ",".join(new_sub_ips)
|
||||
combined_val = ",".join(combined)
|
||||
|
||||
try:
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
|
||||
from server.infrastructure.settings_broadcast import publish_setting_changes
|
||||
|
||||
from server.utils.display_time import format_beijing
|
||||
|
||||
refresh_at = format_beijing(with_label=False)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = SettingRepositoryImpl(session)
|
||||
await repo.set("login_subscription_ips", sub_val)
|
||||
await repo.set("login_allowed_ips", combined_val)
|
||||
await repo.set("login_subscription_last_refresh", refresh_at)
|
||||
|
||||
settings.LOGIN_SUBSCRIPTION_IPS = sub_val
|
||||
settings.LOGIN_ALLOWED_IPS = combined_val
|
||||
settings.LOGIN_SUBSCRIPTION_LAST_REFRESH = refresh_at
|
||||
|
||||
await publish_setting_changes({
|
||||
"login_subscription_ips": sub_val,
|
||||
"login_allowed_ips": combined_val,
|
||||
"login_subscription_last_refresh": refresh_at,
|
||||
})
|
||||
|
||||
_last_refresh_at = refresh_at
|
||||
_last_subscription_count = len(new_sub_ips)
|
||||
logger.info(
|
||||
"Subscription IPs refreshed: %d → combined %d (+ %d manual)",
|
||||
len(new_sub_ips), len(combined), len(manual_ips),
|
||||
)
|
||||
return RefreshResult(
|
||||
ok=True,
|
||||
subscription_ips=tuple(new_sub_ips),
|
||||
last_refresh=_last_refresh_at,
|
||||
subscription_count=len(new_sub_ips),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to save subscription IPs: %s", e)
|
||||
return RefreshResult(ok=False, error=f"保存订阅 IP 失败: {e}")
|
||||
|
||||
|
||||
def get_last_refresh_time() -> str:
|
||||
if _last_refresh_at:
|
||||
return _last_refresh_at
|
||||
from server.config import settings
|
||||
return (getattr(settings, "LOGIN_SUBSCRIPTION_LAST_REFRESH", "") or "").strip()
|
||||
|
||||
|
||||
def get_subscription_count() -> int:
|
||||
return _last_subscription_count
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Nightly cron: auto-detect site domain for IP-only servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from server.application.services.ip_domain_detect_schedule import (
|
||||
_cron_expr,
|
||||
_enabled,
|
||||
run_scheduled_ip_domain_detect,
|
||||
)
|
||||
from server.background.schedule_runner import _cron_match
|
||||
|
||||
logger = logging.getLogger("nexus.ip_domain_detect_loop")
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
async def ip_domain_detect_loop() -> None:
|
||||
"""Primary worker: fire scheduled detect-domain when cron matches (Beijing wall clock)."""
|
||||
while True:
|
||||
try:
|
||||
if _enabled():
|
||||
now = datetime.now(timezone.utc)
|
||||
if _cron_match(_cron_expr(), now):
|
||||
result = await run_scheduled_ip_domain_detect()
|
||||
if result.get("action") == "started":
|
||||
logger.info(
|
||||
"IP domain detect schedule: job_id=%s servers=%s",
|
||||
result.get("job_id"),
|
||||
result.get("server_count"),
|
||||
)
|
||||
elif result.get("reason") not in ("already_ran_today",):
|
||||
logger.debug("IP domain detect schedule: %s", result)
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("IP domain detect loop error")
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Daily cron: SSH preflight on offline servers; optional Telegram offline report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from server.application.services.offline_daily_report_schedule import (
|
||||
_cron_expr,
|
||||
_enabled,
|
||||
_health_check_enabled,
|
||||
run_scheduled_offline_daily_report,
|
||||
run_scheduled_offline_preflight,
|
||||
)
|
||||
from server.background.schedule_runner import _cron_match
|
||||
|
||||
logger = logging.getLogger("nexus.offline_daily_report_loop")
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
async def offline_daily_report_loop() -> None:
|
||||
"""Primary worker: SSH preflight and/or Telegram report when cron matches (Beijing)."""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
if _cron_match(_cron_expr(), now):
|
||||
preflight_stats: dict[str, int] | None = None
|
||||
if _health_check_enabled():
|
||||
preflight_result = await run_scheduled_offline_preflight()
|
||||
if preflight_result.get("action") == "checked":
|
||||
preflight_stats = preflight_result.get("preflight")
|
||||
logger.info(
|
||||
"Offline SSH preflight: checked=%s online=%s offline=%s",
|
||||
preflight_stats.get("checked") if preflight_stats else 0,
|
||||
preflight_stats.get("online") if preflight_stats else 0,
|
||||
preflight_stats.get("offline") if preflight_stats else 0,
|
||||
)
|
||||
elif preflight_result.get("reason") not in ("already_ran_today",):
|
||||
logger.debug("Offline SSH preflight: %s", preflight_result)
|
||||
|
||||
if _enabled():
|
||||
result = await run_scheduled_offline_daily_report(
|
||||
preflight=preflight_stats,
|
||||
)
|
||||
if result.get("action") == "sent":
|
||||
logger.info(
|
||||
"Offline daily report: monitored=%s offline=%s",
|
||||
result.get("monitored"),
|
||||
result.get("offline"),
|
||||
)
|
||||
elif result.get("reason") not in ("already_ran_today",):
|
||||
logger.debug("Offline daily report: %s", result)
|
||||
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Offline daily report loop error")
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Nexus — Retry Runner Background Task
|
||||
Every 5 minutes: check pending PushRetryJobs, retry failed pushes with exponential backoff.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.domain.models import PushRetryJob
|
||||
|
||||
logger = logging.getLogger("nexus.retry_runner")
|
||||
|
||||
RETRY_CHECK_INTERVAL = 300 # 5 minutes
|
||||
RETRY_BACKOFF_BASE = 60 # seconds — doubles each retry
|
||||
|
||||
|
||||
def _backoff_seconds(retry_count: int) -> int:
|
||||
return RETRY_BACKOFF_BASE * (2 ** min(max(retry_count - 1, 0), 6))
|
||||
|
||||
|
||||
def apply_push_retry_outcome(
|
||||
job: PushRetryJob,
|
||||
sync_result: dict,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
"""Update retry job fields from a sync_files() result (retry_count already incremented)."""
|
||||
sync_error = sync_result.get("error")
|
||||
sync_total = int(sync_result.get("total") or 0)
|
||||
sync_completed = int(sync_result.get("completed") or 0)
|
||||
sync_failed = int(sync_result.get("failed") or 0)
|
||||
sync_cancelled = int(sync_result.get("cancelled") or 0)
|
||||
|
||||
if sync_error or sync_total == 0:
|
||||
job.last_error = (sync_error or "推送源不可用或无目标服务器")[:500]
|
||||
if job.retry_count >= job.max_retries:
|
||||
job.status = "failed"
|
||||
else:
|
||||
job.status = "pending"
|
||||
job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count))
|
||||
return
|
||||
|
||||
if sync_failed == 0 and sync_completed > 0:
|
||||
job.status = "completed"
|
||||
return
|
||||
|
||||
if sync_cancelled > 0 and sync_failed == 0 and sync_completed == 0:
|
||||
job.status = "failed"
|
||||
job.last_error = "推送已取消"
|
||||
return
|
||||
|
||||
if job.retry_count >= job.max_retries:
|
||||
job.status = "failed"
|
||||
job.last_error = f"Retry exhausted after {job.retry_count} attempts"
|
||||
return
|
||||
|
||||
job.status = "pending"
|
||||
job.next_retry_at = now + timedelta(seconds=_backoff_seconds(job.retry_count))
|
||||
job.last_error = f"Retry {job.retry_count} failed"
|
||||
|
||||
|
||||
async def _claim_next_retry_job(session, now: datetime) -> PushRetryJob | None:
|
||||
"""Claim one due retry job with row lock (SKIP LOCKED)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(PushRetryJob)
|
||||
.where(
|
||||
PushRetryJob.status == "pending",
|
||||
PushRetryJob.retry_count < PushRetryJob.max_retries,
|
||||
PushRetryJob.next_retry_at <= now,
|
||||
)
|
||||
.limit(1)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
job = result.scalar_one_or_none()
|
||||
if not job:
|
||||
return None
|
||||
job.status = "running"
|
||||
await session.commit()
|
||||
return job
|
||||
|
||||
|
||||
async def retry_runner_loop():
|
||||
"""Background task: every 5min, retry pending retry jobs that are due."""
|
||||
logger.info("Retry runner loop started (interval: 5min)")
|
||||
while True:
|
||||
await asyncio.sleep(RETRY_CHECK_INTERVAL)
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
now = datetime.now(timezone.utc)
|
||||
retried = 0
|
||||
|
||||
while True:
|
||||
job = await _claim_next_retry_job(session, now)
|
||||
if not job:
|
||||
break
|
||||
|
||||
job_id = job.id
|
||||
try:
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||
|
||||
engine = SyncEngineV2(
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
sync_log_repo=SyncLogRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
retry_repo=PushRetryJobRepositoryImpl(session),
|
||||
)
|
||||
|
||||
sync_result = await engine.sync_files(
|
||||
server_ids=[job.server_id],
|
||||
source_path=job.source_path,
|
||||
target_path=job.target_path,
|
||||
trigger_type="retry",
|
||||
operator=job.operator or "retry_runner",
|
||||
)
|
||||
|
||||
job.retry_count += 1
|
||||
apply_push_retry_outcome(job, sync_result, now=now)
|
||||
|
||||
if job.status == "completed":
|
||||
logger.info(
|
||||
f"Retry job {job_id}: success after {job.retry_count} attempts"
|
||||
)
|
||||
elif job.status == "failed":
|
||||
logger.warning(
|
||||
f"Retry job {job_id}: failed — {job.last_error}"
|
||||
)
|
||||
else:
|
||||
backoff = int((job.next_retry_at - now).total_seconds())
|
||||
logger.warning(
|
||||
f"Retry job {job_id}: attempt {job.retry_count} pending, "
|
||||
f"next retry in {backoff}s — {job.last_error}"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
retried += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Retry job {job_id} execution error: {e}")
|
||||
await session.rollback()
|
||||
job = await session.get(PushRetryJob, job_id)
|
||||
if not job:
|
||||
continue
|
||||
job.retry_count += 1
|
||||
job.last_error = str(e)[:500]
|
||||
if job.retry_count >= job.max_retries:
|
||||
job.status = "failed"
|
||||
logger.warning(
|
||||
f"Retry job {job_id}: max retries reached (exception), marking failed"
|
||||
)
|
||||
else:
|
||||
job.status = "pending"
|
||||
job.next_retry_at = now + timedelta(
|
||||
seconds=_backoff_seconds(job.retry_count)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if retried:
|
||||
logger.info(f"Retry runner: {retried} jobs processed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Retry runner check failed: {e}")
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Nexus — Schedule Runner Background Task
|
||||
Every 60 seconds: check PushSchedule cron expressions, trigger due schedules.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.domain.models import PushSchedule
|
||||
from server.utils.display_time import ensure_utc, operator_wall, to_beijing, to_naive_utc
|
||||
|
||||
logger = logging.getLogger("nexus.schedule_runner")
|
||||
|
||||
SCHEDULE_CHECK_INTERVAL = 60 # seconds
|
||||
|
||||
|
||||
def _cron_match(cron_expr: str, at_utc: datetime) -> bool:
|
||||
"""Match 5-field cron against operator (Beijing) wall clock at UTC instant *at_utc*."""
|
||||
parts = cron_expr.strip().split()
|
||||
if len(parts) != 5:
|
||||
return False
|
||||
|
||||
minute, hour, day, month, dow = operator_wall(at_utc)
|
||||
fields = [minute, hour, day, month, dow]
|
||||
|
||||
for current_val, field_expr in zip(fields, parts):
|
||||
if not _field_match(field_expr, current_val):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def rollback_schedule_after_failed_run(schedule, prev_last_run_at, run_mode: str) -> None:
|
||||
"""Restore schedule so cron/once can fire again after execution failure."""
|
||||
schedule.last_run_at = prev_last_run_at
|
||||
if run_mode == "once":
|
||||
schedule.enabled = True
|
||||
|
||||
|
||||
def _field_match(expr: str, value: int) -> bool:
|
||||
"""Match a single cron field expression against a value.
|
||||
|
||||
Supports: * (any), */N (step), specific values, comma-separated lists,
|
||||
and range (N-M). Each comma-separated part is tried independently;
|
||||
the function returns True as soon as any part matches.
|
||||
"""
|
||||
for part in expr.split(","):
|
||||
if part == "*":
|
||||
return True
|
||||
if part.startswith("*/"):
|
||||
step = int(part[2:])
|
||||
if step == 0:
|
||||
continue # */0 is invalid; skip rather than ZeroDivisionError
|
||||
if value % step == 0:
|
||||
return True
|
||||
continue
|
||||
if "-" in part:
|
||||
low, high = part.split("-", 1)
|
||||
if int(low) <= value <= int(high):
|
||||
return True
|
||||
else:
|
||||
# cron: 7 also means Sunday (=0)
|
||||
if value == int(part) or (int(part) == 7 and value == 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _compute_next_cron_run(cron_expr: str, after_utc: datetime) -> datetime | None:
|
||||
"""Return next UTC naive instant strictly after *after_utc* matching *cron_expr* (Beijing wall)."""
|
||||
after = ensure_utc(after_utc)
|
||||
candidate_bj = to_beijing(after).replace(second=0, microsecond=0) + timedelta(minutes=1)
|
||||
limit_bj = to_beijing(after) + timedelta(days=366)
|
||||
while candidate_bj <= limit_bj:
|
||||
at_utc = candidate_bj.astimezone(timezone.utc)
|
||||
if _cron_match(cron_expr, at_utc):
|
||||
return to_naive_utc(at_utc)
|
||||
candidate_bj += timedelta(minutes=1)
|
||||
return None
|
||||
|
||||
|
||||
def compute_schedule_next_run(schedule: PushSchedule, now: datetime | None = None) -> datetime | None:
|
||||
"""Compute next scheduled run time for API display."""
|
||||
if not schedule.enabled:
|
||||
return None
|
||||
now = now or datetime.now(timezone.utc)
|
||||
run_mode = getattr(schedule, "run_mode", None) or "cron"
|
||||
|
||||
if run_mode == "once":
|
||||
fire_at = getattr(schedule, "fire_at", None)
|
||||
if not fire_at or schedule.last_run_at:
|
||||
return None
|
||||
now_naive = now.replace(tzinfo=None)
|
||||
fire_naive = fire_at.replace(tzinfo=None) if fire_at.tzinfo else fire_at
|
||||
if fire_naive <= now_naive:
|
||||
return None
|
||||
return fire_at
|
||||
|
||||
cron_expr = schedule.cron_expr
|
||||
if not cron_expr:
|
||||
return None
|
||||
return _compute_next_cron_run(cron_expr.strip(), now)
|
||||
|
||||
|
||||
def _schedule_is_due(schedule: PushSchedule, now: datetime) -> bool:
|
||||
"""Return True if *schedule* should fire at *now* (cron or once)."""
|
||||
run_mode = getattr(schedule, "run_mode", None) or "cron"
|
||||
|
||||
if run_mode == "once":
|
||||
fire_at = getattr(schedule, "fire_at", None)
|
||||
if not fire_at:
|
||||
return False
|
||||
now_naive = now.replace(tzinfo=None)
|
||||
fire_naive = fire_at.replace(tzinfo=None) if fire_at.tzinfo else fire_at
|
||||
if fire_naive > now_naive:
|
||||
return False
|
||||
if schedule.last_run_at:
|
||||
return False
|
||||
return True
|
||||
|
||||
cron_expr = getattr(schedule, "cron_expr", None)
|
||||
if not cron_expr or not _cron_match(cron_expr, ensure_utc(now)):
|
||||
return False
|
||||
if schedule.last_run_at:
|
||||
last_run = schedule.last_run_at
|
||||
now_naive = now.replace(tzinfo=None)
|
||||
if last_run.tzinfo is not None:
|
||||
last_run = last_run.replace(tzinfo=None)
|
||||
seconds_since = (now_naive - last_run).total_seconds()
|
||||
if seconds_since < SCHEDULE_CHECK_INTERVAL:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def schedule_runner_loop():
|
||||
"""Background task: check schedules every 60s, trigger due cron jobs."""
|
||||
logger.info("Schedule runner loop started (interval: 60s)")
|
||||
while True:
|
||||
await asyncio.sleep(SCHEDULE_CHECK_INTERVAL)
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
from sqlalchemy import select
|
||||
id_result = await session.execute(
|
||||
select(PushSchedule.id).where(PushSchedule.enabled == True)
|
||||
)
|
||||
schedule_ids = list(id_result.scalars().all())
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
triggered = 0
|
||||
|
||||
for sched_id in schedule_ids:
|
||||
try:
|
||||
lock_result = await session.execute(
|
||||
select(PushSchedule)
|
||||
.where(
|
||||
PushSchedule.id == sched_id,
|
||||
PushSchedule.enabled == True,
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
schedule = lock_result.scalar_one_or_none()
|
||||
if not schedule:
|
||||
continue
|
||||
|
||||
if not _schedule_is_due(schedule, now):
|
||||
await session.rollback()
|
||||
continue
|
||||
|
||||
run_mode = getattr(schedule, "run_mode", None) or "cron"
|
||||
|
||||
# Parse server_ids from JSON
|
||||
try:
|
||||
server_ids = json.loads(schedule.server_ids)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.error(f"Schedule {schedule.id}: invalid server_ids JSON")
|
||||
await session.rollback()
|
||||
continue
|
||||
|
||||
if not server_ids:
|
||||
await session.rollback()
|
||||
continue
|
||||
|
||||
# Mark as running BEFORE execution to prevent double-trigger
|
||||
prev_last_run_at = schedule.last_run_at
|
||||
schedule.last_run_at = to_naive_utc(now)
|
||||
if run_mode == "once":
|
||||
schedule.enabled = False
|
||||
await session.commit()
|
||||
|
||||
schedule_type = getattr(schedule, "schedule_type", "push") or "push"
|
||||
execution_ok = True
|
||||
|
||||
try:
|
||||
if schedule_type == "script":
|
||||
# ── Script execution schedule ──
|
||||
from server.application.services.script_service import ScriptService
|
||||
from server.infrastructure.database.script_repo import (
|
||||
ScriptRepositoryImpl, ScriptExecutionRepositoryImpl,
|
||||
)
|
||||
from server.infrastructure.database.db_credential_repo import DbCredentialRepositoryImpl
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
|
||||
cmd = getattr(schedule, 'script_content', None) or ''
|
||||
script_id = getattr(schedule, 'script_id', None)
|
||||
|
||||
# If no inline command, load from script library
|
||||
if not cmd and script_id:
|
||||
script = await ScriptRepositoryImpl(session).get_by_id(script_id)
|
||||
if script:
|
||||
cmd = script.content or ''
|
||||
|
||||
if not cmd:
|
||||
logger.error(f"Schedule '{schedule.name}': no command or script content, skipping")
|
||||
execution_ok = False
|
||||
else:
|
||||
svc = ScriptService(
|
||||
script_repo=ScriptRepositoryImpl(session),
|
||||
execution_repo=ScriptExecutionRepositoryImpl(session),
|
||||
credential_repo=DbCredentialRepositoryImpl(session),
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
)
|
||||
timeout = getattr(schedule, 'exec_timeout', None) or 60
|
||||
long_task = bool(getattr(schedule, 'long_task', False))
|
||||
execution = await svc.execute_command(
|
||||
command=cmd,
|
||||
server_ids=server_ids,
|
||||
script_id=script_id,
|
||||
timeout=timeout,
|
||||
operator=f"schedule:{schedule.name}",
|
||||
long_task=long_task,
|
||||
)
|
||||
logger.info(
|
||||
f"Schedule '{schedule.name}' (script) triggered: "
|
||||
f"execution_id={execution.id} on {len(server_ids)} servers"
|
||||
)
|
||||
else:
|
||||
# ── File push schedule (original behaviour) ──
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||
|
||||
engine = SyncEngineV2(
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
sync_log_repo=SyncLogRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
retry_repo=PushRetryJobRepositoryImpl(session),
|
||||
)
|
||||
|
||||
result = await engine.sync_files(
|
||||
server_ids=server_ids,
|
||||
source_path=schedule.source_path or '/tmp',
|
||||
target_path=getattr(schedule, "target_path", None) or None,
|
||||
sync_mode=getattr(schedule, "sync_mode", None) or "incremental",
|
||||
trigger_type="schedule",
|
||||
operator=f"schedule:{schedule.name}",
|
||||
)
|
||||
logger.info(
|
||||
f"Schedule '{schedule.name}' (push) triggered: "
|
||||
f"{result['completed']}/{result['total']} succeeded"
|
||||
)
|
||||
if result.get("error"):
|
||||
execution_ok = False
|
||||
elif result.get("total", 0) > 0 and result.get("completed", 0) == 0:
|
||||
execution_ok = False
|
||||
except Exception as exec_err:
|
||||
logger.error(f"Schedule {schedule.id} execution failed: {exec_err}")
|
||||
execution_ok = False
|
||||
|
||||
if not execution_ok:
|
||||
fresh = await session.get(PushSchedule, sched_id)
|
||||
if fresh:
|
||||
rollback_schedule_after_failed_run(
|
||||
fresh, prev_last_run_at, run_mode
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
triggered += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Schedule {sched_id} setup/parse failed: {e}")
|
||||
await session.rollback()
|
||||
|
||||
if triggered:
|
||||
logger.info(f"Schedule runner: {triggered} schedules triggered")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Schedule runner check failed: {e}")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Batch-flush script execution live state from Redis to MySQL (every 60s, up to 50 rows/cycle)."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from server.domain.models import AuditLog
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.infrastructure.database.script_repo import ScriptExecutionRepositoryImpl
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.redis.script_execution_store import (
|
||||
FLUSH_INTERVAL_SECONDS,
|
||||
detect_stuck_executions,
|
||||
flush_pending_batch,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("nexus.script_execution_flush")
|
||||
|
||||
|
||||
async def script_execution_flush_loop():
|
||||
"""Every minute: flush queued script execution snapshots to MySQL."""
|
||||
logger.info("Script execution flush loop started (interval=%ss)", FLUSH_INTERVAL_SECONDS)
|
||||
while True:
|
||||
await asyncio.sleep(FLUSH_INTERVAL_SECONDS)
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = ScriptExecutionRepositoryImpl(session)
|
||||
audit_repo = AuditLogRepositoryImpl(session)
|
||||
|
||||
async def _audit(action, target_type, target_id, operator, _repo=audit_repo): # noqa: B023
|
||||
await _repo.create(AuditLog(
|
||||
admin_username=operator or "system",
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
detail=f"执行ID={target_id}",
|
||||
))
|
||||
|
||||
stuck = await detect_stuck_executions(repo, _audit)
|
||||
count = await flush_pending_batch(repo)
|
||||
if stuck:
|
||||
logger.info("Script execution: auto-marked %s stuck", stuck)
|
||||
if count:
|
||||
logger.info("Script execution flush: %s record(s) written to MySQL", count)
|
||||
except Exception as e:
|
||||
logger.error("Script execution flush loop error: %s", e)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Nexus — Python Self-Monitor Background Task
|
||||
Every 30 seconds: check Redis, MySQL, WebSocket connections.
|
||||
If critical service fails → send Telegram system alert.
|
||||
If service recovers → send Telegram recovery notification.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
from server.infrastructure.telegram import send_telegram_system_alert
|
||||
from server.api.websocket import manager as ws_manager, sync_manager as ws_sync_manager
|
||||
from server.config import settings
|
||||
|
||||
logger = logging.getLogger("nexus.self_monitor")
|
||||
|
||||
MONITOR_INTERVAL = 30 # seconds
|
||||
_TELEGRAM_COOLDOWN = 300 # 5 minutes — same alert won't re-push Telegram
|
||||
|
||||
# Track previous state for recovery detection
|
||||
_prev_redis_ok = True
|
||||
_prev_mysql_ok = True
|
||||
# Track last Telegram push time per alert key (prevent spam)
|
||||
_last_telegram_time: dict = {}
|
||||
|
||||
|
||||
async def self_monitor_loop():
|
||||
"""Background task: every 30s, self-check critical services.
|
||||
|
||||
Checks:
|
||||
1. Redis connection — if lost, send Telegram warning; if recovered, send recovery
|
||||
2. MySQL connection — if lost, send Telegram warning; if recovered, send recovery
|
||||
3. WebSocket client count (via ConnectionManager)
|
||||
"""
|
||||
global _prev_redis_ok, _prev_mysql_ok
|
||||
|
||||
def _should_send(key: str) -> bool:
|
||||
"""Rate-limit Telegram pushes to 1 per 5 minutes per alert key."""
|
||||
now = time.monotonic()
|
||||
last = _last_telegram_time.get(key, 0)
|
||||
if now - last < _TELEGRAM_COOLDOWN:
|
||||
return False
|
||||
_last_telegram_time[key] = now
|
||||
return True
|
||||
|
||||
logger.info("Self-monitor loop started (interval: 30s)")
|
||||
while True:
|
||||
await asyncio.sleep(MONITOR_INTERVAL)
|
||||
|
||||
# ── Redis check ──
|
||||
redis_ok = True
|
||||
try:
|
||||
redis = get_redis()
|
||||
await redis.ping()
|
||||
except Exception:
|
||||
redis_ok = False
|
||||
logger.warning("Redis health check failed", exc_info=True)
|
||||
try:
|
||||
if settings.REDIS_URL and _should_send("NOTIFY_SYSTEM_REDIS"):
|
||||
await send_telegram_system_alert(
|
||||
summary="Redis 连接失败,无法 ping 通",
|
||||
title="Redis 连接异常",
|
||||
notify_key="NOTIFY_SYSTEM_REDIS",
|
||||
icon="🔴",
|
||||
details=[
|
||||
"检测来源:Layer 2 self_monitor(30 秒周期)",
|
||||
"影响范围:子机实时心跳、在线状态、Redis 缓存读写可能中断",
|
||||
"数据后果:前端仪表盘实时数据可能停滞,历史数据仍可从 MySQL 读取",
|
||||
],
|
||||
action="请检查 1Panel Redis 容器状态与 NEXUS_REDIS_URL 配置",
|
||||
)
|
||||
except Exception:
|
||||
logger.error("Failed to send Redis alert via Telegram")
|
||||
|
||||
# Redis recovery notification
|
||||
if redis_ok and not _prev_redis_ok:
|
||||
try:
|
||||
await send_telegram_system_alert(
|
||||
summary="Redis 连接已恢复,ping 检测通过",
|
||||
title="Redis 连接恢复",
|
||||
notify_key="NOTIFY_SYSTEM_REDIS",
|
||||
icon="🟢",
|
||||
details=[
|
||||
"检测来源:Layer 2 self_monitor(30 秒周期)",
|
||||
"当前状态:Redis 可正常读写,实时心跳应恢复更新",
|
||||
],
|
||||
)
|
||||
_last_telegram_time.pop("NOTIFY_SYSTEM_REDIS", None) # clear cooldown
|
||||
except Exception:
|
||||
logger.error("Failed to send Redis recovery via Telegram")
|
||||
logger.info("Redis connection recovered")
|
||||
_prev_redis_ok = redis_ok
|
||||
|
||||
# ── MySQL check ──
|
||||
mysql_ok = True
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
except Exception:
|
||||
mysql_ok = False
|
||||
logger.warning("MySQL health check failed", exc_info=True)
|
||||
try:
|
||||
if _should_send("NOTIFY_SYSTEM_MYSQL"):
|
||||
await send_telegram_system_alert(
|
||||
summary="MySQL 连接失败,SELECT 1 探测未通过",
|
||||
title="MySQL 数据库异常",
|
||||
notify_key="NOTIFY_SYSTEM_MYSQL",
|
||||
icon="🔴",
|
||||
details=[
|
||||
"检测来源:Layer 2 self_monitor(30 秒周期)",
|
||||
"影响范围:设置、子机列表、审计、推送任务等所有 DB 读写",
|
||||
"严重程度:平台核心功能可能不可用",
|
||||
],
|
||||
action="请检查 1Panel MySQL 容器、磁盘空间与 DATABASE_URL 配置",
|
||||
)
|
||||
except Exception:
|
||||
logger.error("Failed to send MySQL alert via Telegram", exc_info=True)
|
||||
|
||||
# MySQL recovery notification
|
||||
if mysql_ok and not _prev_mysql_ok:
|
||||
try:
|
||||
await send_telegram_system_alert(
|
||||
summary="MySQL 连接已恢复,SELECT 1 探测通过",
|
||||
title="MySQL 数据库恢复",
|
||||
notify_key="NOTIFY_SYSTEM_MYSQL",
|
||||
icon="🟢",
|
||||
details=[
|
||||
"检测来源:Layer 2 self_monitor(30 秒周期)",
|
||||
"当前状态:数据库读写应已恢复正常",
|
||||
],
|
||||
)
|
||||
_last_telegram_time.pop("NOTIFY_SYSTEM_MYSQL", None) # clear cooldown
|
||||
except Exception:
|
||||
logger.error("Failed to send MySQL recovery via Telegram")
|
||||
logger.info("MySQL connection recovered")
|
||||
_prev_mysql_ok = mysql_ok
|
||||
|
||||
# ── Log status ──
|
||||
logger.debug(
|
||||
f"Self-monitor: redis={'ok' if redis_ok else 'error'}, "
|
||||
f"mysql={'ok' if mysql_ok else 'error'}, "
|
||||
f"ws_alert_clients={ws_manager.client_count}, "
|
||||
f"ws_sync_clients={ws_sync_manager.client_count}"
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Background reconciliation for server batch jobs — stuck detection (60s interval)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from server.application.services.server_batch_service import detect_stuck_batch_jobs
|
||||
from server.infrastructure.redis.server_batch_store import RECONCILE_INTERVAL_SECONDS
|
||||
|
||||
logger = logging.getLogger("nexus.server_batch_reconcile")
|
||||
|
||||
|
||||
async def server_batch_reconcile_loop() -> None:
|
||||
"""Every minute: auto-finalize batch jobs with no Redis progress for 1h."""
|
||||
logger.info(
|
||||
"Server batch reconcile loop started (interval=%ss)",
|
||||
RECONCILE_INTERVAL_SECONDS,
|
||||
)
|
||||
while True:
|
||||
await asyncio.sleep(RECONCILE_INTERVAL_SECONDS)
|
||||
try:
|
||||
stuck = await detect_stuck_batch_jobs()
|
||||
if stuck:
|
||||
logger.info("Server batch reconcile: %s stuck job(s) finalized", stuck)
|
||||
except Exception as e:
|
||||
logger.error("Server batch reconcile loop error: %s", e)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Nexus — Server offline alert background task.
|
||||
|
||||
Every 30 seconds: detect online→offline transitions among Agent-monitored servers
|
||||
and push one Telegram alert per transition (edge-triggered).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from server.application.server_batch_common import batch_server_display_name
|
||||
from server.application.server_connectivity import (
|
||||
REDIS_KEY_PREFIX,
|
||||
heartbeat_indicates_online,
|
||||
monitored_server_filter,
|
||||
)
|
||||
from server.domain.models import Server
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.redis.client import get_redis
|
||||
|
||||
logger = logging.getLogger("nexus.server_offline_monitor")
|
||||
|
||||
MONITOR_INTERVAL = 30 # seconds — align with self_monitor
|
||||
_BATCH_SIZE = 200
|
||||
|
||||
# server_id → last known online (None = cold start, no alert)
|
||||
_prev_online: dict[int, bool] = {}
|
||||
|
||||
|
||||
async def _load_monitored_servers() -> list[tuple[int, str]]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Server.id, Server.name, Server.domain, Server.description).where(
|
||||
monitored_server_filter()
|
||||
)
|
||||
)
|
||||
return [
|
||||
(
|
||||
int(row[0]),
|
||||
batch_server_display_name(
|
||||
Server(id=int(row[0]), name=row[1], domain=row[2], description=row[3])
|
||||
),
|
||||
)
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
|
||||
async def _check_offline_transitions() -> int:
|
||||
"""Scan monitored servers; return count of new offline alerts fired."""
|
||||
servers = await _load_monitored_servers()
|
||||
if not servers:
|
||||
return 0
|
||||
|
||||
redis = get_redis()
|
||||
alerts_sent = 0
|
||||
|
||||
for i in range(0, len(servers), _BATCH_SIZE):
|
||||
chunk = servers[i : i + _BATCH_SIZE]
|
||||
pipe = redis.pipeline()
|
||||
for server_id, _ in chunk:
|
||||
pipe.hgetall(f"{REDIS_KEY_PREFIX}{server_id}")
|
||||
heartbeats = await pipe.execute()
|
||||
|
||||
for (server_id, server_name), hb in zip(chunk, heartbeats):
|
||||
currently_online = heartbeat_indicates_online(hb)
|
||||
prev_online = _prev_online.get(server_id)
|
||||
|
||||
if prev_online is None:
|
||||
_prev_online[server_id] = currently_online
|
||||
continue
|
||||
|
||||
if prev_online and not currently_online:
|
||||
from server.api.websocket import broadcast_offline_alert
|
||||
|
||||
last_hb = (hb or {}).get("last_heartbeat", "")
|
||||
await broadcast_offline_alert(server_id, server_name, last_hb)
|
||||
alerts_sent += 1
|
||||
logger.warning("Server offline alert: id=%s name=%s", server_id, server_name)
|
||||
|
||||
_prev_online[server_id] = currently_online
|
||||
|
||||
return alerts_sent
|
||||
|
||||
|
||||
async def server_offline_monitor_loop():
|
||||
"""Background task: edge-detect server offline and notify Telegram once per event."""
|
||||
logger.info("Server offline monitor started (interval: %ss)", MONITOR_INTERVAL)
|
||||
while True:
|
||||
await asyncio.sleep(MONITOR_INTERVAL)
|
||||
try:
|
||||
sent = await _check_offline_transitions()
|
||||
if sent:
|
||||
logger.info("Server offline monitor: %s alert(s) sent", sent)
|
||||
except Exception:
|
||||
logger.error("Server offline monitor tick failed", exc_info=True)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Periodic purge of sync_logs older than SYNC_LOG_RETENTION_DAYS (design: 30 days)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.sync_log_repo import (
|
||||
SYNC_LOG_RETENTION_DAYS,
|
||||
SyncLogRepositoryImpl,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("nexus.sync_log_purge")
|
||||
|
||||
PURGE_INTERVAL_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
async def purge_stale_sync_logs() -> int:
|
||||
"""Delete sync_logs rows older than retention window. Returns rows removed."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = SyncLogRepositoryImpl(session)
|
||||
return await repo.purge_older_than(retention_days=SYNC_LOG_RETENTION_DAYS)
|
||||
|
||||
|
||||
async def sync_log_purge_loop() -> None:
|
||||
"""Run daily on primary worker; also invoked once at API startup."""
|
||||
while True:
|
||||
try:
|
||||
count = await purge_stale_sync_logs()
|
||||
if count:
|
||||
logger.info(
|
||||
"Sync log purge: removed %s row(s) older than %s days",
|
||||
count,
|
||||
SYNC_LOG_RETENTION_DAYS,
|
||||
)
|
||||
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Sync log purge loop error")
|
||||
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Background file push — API returns immediately; progress via WebSocket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger("nexus.sync_push_runner")
|
||||
|
||||
|
||||
async def run_sync_files_background(
|
||||
*,
|
||||
server_ids: List[int],
|
||||
source_path: str,
|
||||
target_path: Optional[str],
|
||||
sync_mode: str,
|
||||
operator: str,
|
||||
batch_size: int,
|
||||
concurrency: int,
|
||||
batch_id: str,
|
||||
) -> None:
|
||||
"""Execute sync_files in an isolated DB session (not request-scoped)."""
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
|
||||
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
engine = SyncEngineV2(
|
||||
server_repo=ServerRepositoryImpl(session),
|
||||
sync_log_repo=SyncLogRepositoryImpl(session),
|
||||
audit_repo=AuditLogRepositoryImpl(session),
|
||||
retry_repo=PushRetryJobRepositoryImpl(session),
|
||||
)
|
||||
await engine.sync_files(
|
||||
server_ids=server_ids,
|
||||
source_path=source_path,
|
||||
target_path=target_path,
|
||||
sync_mode=sync_mode,
|
||||
operator=operator,
|
||||
batch_size=batch_size,
|
||||
concurrency=concurrency,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Background sync_files failed: batch_id=%s", batch_id)
|
||||
|
||||
|
||||
def schedule_sync_files(**kwargs) -> None:
|
||||
"""Fire-and-forget push task on the running event loop."""
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(
|
||||
run_sync_files_background(**kwargs),
|
||||
name=f"sync_push:{kwargs.get('batch_id', '')}",
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Nightly cron: auto-detect target_path for servers still on BT default."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from server.application.services.unset_path_detect_schedule import (
|
||||
_cron_expr,
|
||||
_enabled,
|
||||
run_scheduled_unset_path_detect,
|
||||
)
|
||||
from server.background.schedule_runner import _cron_match
|
||||
|
||||
logger = logging.getLogger("nexus.unset_path_detect_loop")
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
async def unset_path_detect_loop() -> None:
|
||||
"""Primary worker: fire scheduled detect-path when cron matches (Beijing wall clock)."""
|
||||
while True:
|
||||
try:
|
||||
if _enabled():
|
||||
now = datetime.now(timezone.utc)
|
||||
if _cron_match(_cron_expr(), now):
|
||||
result = await run_scheduled_unset_path_detect()
|
||||
if result.get("action") == "started":
|
||||
logger.info(
|
||||
"Unset path detect schedule: job_id=%s servers=%s",
|
||||
result.get("job_id"),
|
||||
result.get("server_count"),
|
||||
)
|
||||
elif result.get("reason") not in ("already_ran_today",):
|
||||
logger.debug("Unset path detect schedule: %s", result)
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Unset path detect loop error")
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Periodic cleanup of stale /tmp/nexus_upload_* ZIP extract directories.
|
||||
|
||||
Removes dirs older than MAX_AGE_HOURS that were left behind after failed/cancelled
|
||||
pushes or abandoned uploads. Successful manual pushes are already cleaned in sync_engine_v2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import glob as glob_module
|
||||
|
||||
from server.utils.posix_paths import UPLOAD_STAGING_PREFIX
|
||||
|
||||
logger = logging.getLogger("nexus.upload_staging_cleanup")
|
||||
|
||||
LOOP_INTERVAL_SECONDS = 3600
|
||||
MAX_AGE_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
def _cleanup_stale_upload_dirs() -> int:
|
||||
"""Remove upload staging dirs not modified within MAX_AGE_SECONDS. Returns count removed."""
|
||||
now = time.time()
|
||||
removed = 0
|
||||
pattern = f"{UPLOAD_STAGING_PREFIX}*"
|
||||
for path in glob_module.glob(pattern):
|
||||
if not os.path.isdir(path):
|
||||
continue
|
||||
try:
|
||||
real = os.path.realpath(path)
|
||||
if not real.startswith(UPLOAD_STAGING_PREFIX):
|
||||
logger.warning("Skip upload cleanup (path escape): %s", path)
|
||||
continue
|
||||
age = now - os.path.getmtime(real)
|
||||
if age < MAX_AGE_SECONDS:
|
||||
continue
|
||||
shutil.rmtree(real, ignore_errors=True)
|
||||
if not os.path.exists(real):
|
||||
removed += 1
|
||||
logger.info("Removed stale upload staging dir (age %.0fh): %s", age / 3600, real)
|
||||
except OSError as exc:
|
||||
logger.warning("Upload staging cleanup skip %s: %s", path, exc)
|
||||
return removed
|
||||
|
||||
|
||||
async def upload_staging_cleanup_loop() -> None:
|
||||
"""Run hourly on primary worker."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(LOOP_INTERVAL_SECONDS)
|
||||
count = await asyncio.to_thread(_cleanup_stale_upload_dirs)
|
||||
if count:
|
||||
logger.info("Upload staging cleanup cycle removed %d director(ies)", count)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Upload staging cleanup loop error")
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Background watch probe loop — 5s sampling for pinned servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
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, ssh_load_avg_probe
|
||||
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,
|
||||
agent_watch_sample_usable,
|
||||
failure_sample,
|
||||
merge_redis_and_ssh,
|
||||
parse_redis_watch_payload,
|
||||
parse_ssh_watch_payload,
|
||||
fill_hardware_gaps,
|
||||
apply_load_avg_to_sample,
|
||||
sample_needs_load_supplement,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("nexus.watch_probe")
|
||||
|
||||
REDIS_HEARTBEAT_PREFIX = "heartbeat:"
|
||||
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)
|
||||
|
||||
|
||||
async def _supplement_load_if_needed(server: Server, sample: WatchProbeSample) -> WatchProbeSample:
|
||||
"""Agent heartbeat omits load_avg; lightweight SSH when full probe was skipped."""
|
||||
if not sample_needs_load_supplement(sample):
|
||||
return sample
|
||||
load_result = await ssh_load_avg_probe(server, timeout=5)
|
||||
if load_result.get("ok"):
|
||||
payload = load_result.get("payload") or {}
|
||||
apply_load_avg_to_sample(sample, payload.get("load_avg"))
|
||||
return sample
|
||||
|
||||
|
||||
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 _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 agent_watch_sample_usable(heartbeat, redis_sample) and redis_sample is not None:
|
||||
sample = redis_sample
|
||||
sample.source = "agent"
|
||||
sample.is_online = True
|
||||
if fetch_processes:
|
||||
procs = await ssh_watch_processes(server, timeout=WATCH_PROBE_TIMEOUT_SEC)
|
||||
if procs:
|
||||
sample.processes_json = procs
|
||||
fill_hardware_gaps(sample)
|
||||
return sample
|
||||
|
||||
# Agent 未进 watch 模式或心跳过期 → SSH psutil(与宝塔同源)
|
||||
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) if redis_sample else ssh_sample
|
||||
elif redis_sample is not None:
|
||||
redis_sample.duration_ms = int(ssh_result.get("duration_ms") or 0)
|
||||
sample = await _supplement_load_if_needed(server, 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
|
||||
|
||||
if sample.probe_status == "ok":
|
||||
fill_hardware_gaps(sample)
|
||||
|
||||
return sample
|
||||
|
||||
|
||||
async def _probe_server_with_pins(
|
||||
*,
|
||||
watch_repo: WatchRepositoryImpl,
|
||||
server_repo: ServerRepositoryImpl,
|
||||
redis,
|
||||
server_id: int,
|
||||
pin_rows: list[dict[str, Any]],
|
||||
now: datetime,
|
||||
fetch_processes: bool,
|
||||
) -> None:
|
||||
"""Probe one server, persist Redis snapshot, WS push, and DB records for all its pins."""
|
||||
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}")
|
||||
sample = await probe_server_metrics(
|
||||
server,
|
||||
heartbeat or None,
|
||||
fetch_processes=fetch_processes,
|
||||
)
|
||||
|
||||
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,
|
||||
processes_json=sample.processes_json,
|
||||
error=sample.error,
|
||||
)
|
||||
|
||||
|
||||
async def trigger_immediate_watch_probe(server_id: int) -> None:
|
||||
"""Eager probe after pin add or monitoring resume — fills Redis before list_slots."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
watch_repo = WatchRepositoryImpl(db)
|
||||
pin_rows = [
|
||||
p
|
||||
for p in await watch_repo.list_active_pins_all(monitoring_only=True)
|
||||
if p["server_id"] == server_id
|
||||
]
|
||||
if not pin_rows:
|
||||
return
|
||||
server_repo = ServerRepositoryImpl(db)
|
||||
redis = get_redis()
|
||||
now = _utc_naive_now()
|
||||
_process_cycle[server_id] += 1
|
||||
fetch_procs = _process_cycle[server_id] % 2 == 0
|
||||
await _probe_server_with_pins(
|
||||
watch_repo=watch_repo,
|
||||
server_repo=server_repo,
|
||||
redis=redis,
|
||||
server_id=server_id,
|
||||
pin_rows=pin_rows,
|
||||
now=now,
|
||||
fetch_processes=fetch_procs,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("immediate watch probe failed for server %s", server_id)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
for server_id, pin_rows in by_server.items():
|
||||
_process_cycle[server_id] += 1
|
||||
fetch_procs = _process_cycle[server_id] % 2 == 0
|
||||
await _probe_server_with_pins(
|
||||
watch_repo=watch_repo,
|
||||
server_repo=server_repo,
|
||||
redis=redis,
|
||||
server_id=server_id,
|
||||
pin_rows=pin_rows,
|
||||
now=now,
|
||||
fetch_processes=fetch_procs,
|
||||
)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user