PingWatch 网络设备离线监控系统

- FastAPI 后端 + Vue 3 前端
- Docker Compose 一键部署
- Casdoor OAuth 认证集成
- LogHive 集中式日志
- 设备批量 CSV 导入/导出
- WebSocket 实时状态推送
- 企业微信告警通知
- fping 高性能并发 Ping 检测

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 15:02:04 +08:00
commit 848f804169
55 changed files with 5941 additions and 0 deletions
View File
+319
View File
@@ -0,0 +1,319 @@
"""
告警服务
职责:
1. 接收 Pinger 的状态变化事件
2. 判断是否需要发送告警
3. 上游心跳检测 + 全量离线抑制
4. 企业微信消息推送(聚合告警)
5. 记录告警事件到数据库
"""
import asyncio
import logging
from datetime import datetime
from typing import Optional
from collections import defaultdict
import httpx
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.device import Device
from app.models.alert_event import AlertEvent, AlertTypeEnum
from app.services.pinger import DeviceStateChange
logger = logging.getLogger("pingwatch.alerter")
class PendingOfflineAlert:
"""等待发送的离线告警(用于聚合)"""
def __init__(self, device: Device, alert_time: datetime):
self.device = device
self.alert_time = alert_time
class Alerter:
"""
告警处理器。
核心逻辑:
- 设备 offline → 收集到待发送队列
- 每轮结束后检查:
a) 上游心跳是否正常?
b) 离线率是否 < 90%
c) 满足条件 → 聚合所有待发告警 → 一条企业微信消息
d) 不满足 → 丢弃本轮告警,记录系统日志
- 设备 recovered → 单独发送恢复通知
"""
def __init__(self):
self._pending_alerts: list[PendingOfflineAlert] = []
self._lock = asyncio.Lock()
# 上游心跳状态
self._upstream_failures = 0
self._upstream_available = True
async def on_state_change(self, change: DeviceStateChange, db: AsyncSession):
"""Pinger 状态变化回调"""
if change.new_status == "offline":
async with self._lock:
self._pending_alerts.append(
PendingOfflineAlert(device=change.device, alert_time=datetime.now())
)
elif change.new_status == "online" and change.old_status == "offline":
# 设备恢复,立即记录并发送恢复通知
await self._handle_recovery(change.device, db)
async def flush_pending(self, db: AsyncSession, total_device_count: int):
"""
每轮结束时调用:处理待发送的离线告警。
判断是否应该抑制告警,然后发送或丢弃。
"""
async with self._lock:
if not self._pending_alerts:
return
pending = self._pending_alerts.copy()
self._pending_alerts.clear()
# 1. 检查上游心跳
upstream_ok = await self._check_upstream()
# 2. 计算离线率
offline_count = len(pending)
offline_ratio = offline_count / max(total_device_count, 1)
# 3. 抑制条件
suppressed = False
suppress_reason = ""
if not upstream_ok:
suppressed = True
suppress_reason = "上游网络不可达(监控节点可能断网)"
elif offline_ratio >= settings.OFFLINE_SUPPRESS_RATIO:
suppressed = True
suppress_reason = f"离线率 {offline_ratio:.0%} >= {settings.OFFLINE_SUPPRESS_RATIO:.0%},疑似监控节点断网"
if suppressed:
logger.warning(
f"告警抑制: {suppress_reason}"
f"本轮 {offline_count} 条告警已丢弃"
)
# 记录系统告警
db.add(AlertEvent(
device_id=0,
alert_type=AlertTypeEnum.system,
message=f"告警抑制: {suppress_reason},丢弃 {offline_count} 条离线告警",
start_at=datetime.now(),
is_resolved=True,
notification_sent=False,
))
await db.commit()
return
# 4. 发送聚合告警
if pending:
await self._send_aggregated_alert(pending, db)
async def _check_upstream(self) -> bool:
"""
上游心跳检测。
连续 UPSTREAM_PING_THRESHOLD 次失败才判定为上游断网。
"""
try:
import subprocess
proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", "3",
settings.UPSTREAM_PING_TARGET,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
await proc.wait()
if proc.returncode == 0:
self._upstream_failures = 0
self._upstream_available = True
return True
else:
self._upstream_failures += 1
if self._upstream_failures >= settings.UPSTREAM_PING_THRESHOLD:
self._upstream_available = False
return False
# 未达到阈值,认为上游还可用
return True
except Exception as e:
logger.error(f"上游心跳检测异常: {e}")
return True # 异常时保守地允许告警
async def _send_aggregated_alert(self, alerts: list[PendingOfflineAlert], db: AsyncSession):
"""发送聚合离线告警"""
now = datetime.now()
# 构建企业微信消息
if len(alerts) == 1:
a = alerts[0]
msg = self._build_offline_message_single(a.device, a.alert_time)
else:
msg = self._build_offline_message_batch(alerts)
# 发送
success = await self._send_wecom_message(msg)
# 记录告警事件
for a in alerts:
db.add(AlertEvent(
device_id=a.device.id,
alert_type=AlertTypeEnum.offline,
message=a.device.name,
start_at=a.alert_time,
is_resolved=False,
notification_sent=success,
))
await db.commit()
if success:
logger.info(f"已推送离线告警: {len(alerts)} 台设备")
else:
logger.error(f"企业微信推送失败: {len(alerts)} 台设备")
async def _handle_recovery(self, device: Device, db: AsyncSession):
"""处理设备恢复"""
now = datetime.now()
# 查找未解决的离线事件
result = await db.execute(
select(AlertEvent)
.where(AlertEvent.device_id == device.id)
.where(AlertEvent.alert_type == AlertTypeEnum.offline)
.where(AlertEvent.is_resolved == False)
.order_by(AlertEvent.created_at.desc())
.limit(1)
)
event = result.scalar_one_or_none()
duration_minutes = None
if event:
delta = now - event.start_at
duration_minutes = int(delta.total_seconds() / 60)
event.end_at = now
event.duration_minutes = duration_minutes
event.is_resolved = True
# 发送恢复通知
msg = self._build_recovery_message(device, now, duration_minutes)
success = await self._send_wecom_message(msg)
if event:
event.notification_sent = success
await db.commit()
if success:
logger.info(f"已推送恢复通知: {device.name}")
else:
logger.error(f"恢复通知推送失败: {device.name}")
# ---------- 消息格式化 ----------
def _build_offline_message_single(self, device: Device, alert_time: datetime) -> str:
"""单台设备离线消息"""
time_str = alert_time.strftime("%Y-%m-%d %H:%M:%S")
return (
f"⛔ 设备离线啦!\n"
f"地址:{device.location or '未知'}\n"
f"时间:{time_str}\n"
f"项目:{device.project_name or '未分组'}\n"
f"设备类型:{self._fmt_device_type(device.device_type)}\n"
f"IP地址:{device.ip}"
)
def _build_offline_message_batch(self, alerts: list[PendingOfflineAlert]) -> str:
"""多台设备聚合离线消息"""
now = alerts[0].alert_time
time_str = now.strftime("%Y-%m-%d %H:%M:%S")
lines = [f"⛔ 设备离线啦!(共 {len(alerts)} 台)\n"]
for a in alerts:
dev = a.device
lines.append(
f"地址:{dev.location or '未知'}\n"
f"时间:{a.alert_time.strftime('%Y-%m-%d %H:%M:%S')}\n"
f"项目:{dev.project_name or '未分组'}\n"
f"设备类型:{self._fmt_device_type(dev.device_type)}\n"
f"IP地址:{dev.ip}\n"
f"{'---' if len(alerts) > 1 else ''}"
)
return "\n".join(lines).rstrip("---\n")
def _build_recovery_message(self, device: Device, recover_time: datetime, duration: Optional[int]) -> str:
"""设备恢复消息"""
time_str = recover_time.strftime("%Y-%m-%d %H:%M:%S")
duration_str = f"{duration}分钟" if duration is not None else "未知"
return (
f"✅ 设备恢复在线!\n"
f"地址:{device.location or '未知'}\n"
f"时间:{time_str}\n"
f"项目:{device.project_name or '未分组'}\n"
f"设备类型:{self._fmt_device_type(device.device_type)}\n"
f"IP地址:{device.ip}\n"
f"离线时长:{duration_str}"
)
def _fmt_device_type(self, dtype) -> str:
mapping = {
"server": "服务器",
"olt": "OLT",
"switch": "交换机",
"firewall": "防火墙",
"other": "其他设备",
}
return mapping.get(str(dtype), str(dtype))
# ---------- 企业微信推送 ----------
async def _get_wecom_token(self) -> Optional[str]:
"""获取企业微信 access_token"""
url = (
f"https://qyapi.weixin.qq.com/cgi-bin/gettoken"
f"?corpid={settings.WECOM_CORP_ID}"
f"&corpsecret={settings.WECOM_APP_SECRET}"
)
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url)
data = resp.json()
if data.get("errcode") == 0:
return data["access_token"]
else:
logger.error(f"获取企业微信 token 失败: {data}")
return None
async def _send_wecom_message(self, content: str) -> bool:
"""发送企业微信应用消息"""
if not settings.WECOM_CORP_ID or not settings.WECOM_APP_SECRET:
logger.warning("企业微信未配置,跳过推送")
logger.info(f"[模拟推送] {content}")
return True # 开发模式
token = await self._get_wecom_token()
if not token:
return False
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
payload = {
"touser": "@all",
"msgtype": "text",
"agentid": settings.WECOM_AGENT_ID,
"text": {"content": content},
"safe": 0,
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json=payload)
data = resp.json()
if data.get("errcode") != 0:
logger.error(f"发送企业微信消息失败: {data}")
return False
return True
+38
View File
@@ -0,0 +1,38 @@
"""数据清理服务"""
import logging
from datetime import datetime, timedelta
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.ping_record import PingRecord
from app.models.alert_event import AlertEvent
from app.core.deps import async_session
logger = logging.getLogger("pingwatch.cleanup")
async def cleanup_old_data():
"""清理超过保留期限的旧数据"""
now = datetime.now()
# 清理 ping_records
ping_cutoff = now - timedelta(days=settings.PING_RECORD_RETENTION_DAYS)
async with async_session() as db:
result = await db.execute(
delete(PingRecord).where(PingRecord.created_at < ping_cutoff)
)
await db.commit()
if result.rowcount > 0:
logger.info(f"清理 ping_records: {result.rowcount}")
# 清理 alert_events
alert_cutoff = now - timedelta(days=settings.ALERT_RETENTION_DAYS)
async with async_session() as db:
result = await db.execute(
delete(AlertEvent).where(AlertEvent.created_at < alert_cutoff)
)
await db.commit()
if result.rowcount > 0:
logger.info(f"清理 alert_events: {result.rowcount}")
+242
View File
@@ -0,0 +1,242 @@
"""
异步 Ping 引擎
核心逻辑:
1. 每轮从数据库加载所有启用设备,批量 fping
2. 记录每台设备本轮 ping 结果(存活/响应时间)
3. 状态机管理设备状态,判断是否从 online→offline 或 offline→online
4. 结果通过回调或队列通知 alerter
"""
import asyncio
import subprocess
import time
import logging
from datetime import datetime
from typing import Optional, Callable, Awaitable
from collections import defaultdict
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.device import Device, DeviceTypeEnum
from app.models.ping_record import PingRecord
logger = logging.getLogger("pingwatch.pinger")
class PingResult:
"""单台设备一轮 ping 的结果"""
def __init__(self, device_id: int, is_alive: bool, response_time_ms: Optional[float] = None):
self.device_id = device_id
self.is_alive = is_alive
self.response_time_ms = response_time_ms
class DeviceStateChange:
"""设备状态变化事件"""
def __init__(self, device: Device, old_status: str, new_status: str, consecutive_failures: int):
self.device = device
self.old_status = old_status
self.new_status = new_status
self.consecutive_failures = consecutive_failures
class Pinger:
"""
Ping 引擎,使用 fping 批量并发检测。
对所有设备进 ping,返回存活状态和响应时间。
"""
def __init__(self):
self._round_num = 0
self._on_state_change: Optional[Callable[[DeviceStateChange], Awaitable[None]]] = None
def on_state_change(self, callback: Callable[[DeviceStateChange], Awaitable[None]]):
"""注册状态变化回调"""
self._on_state_change = callback
async def run_one_round(self, db: AsyncSession) -> list[PingResult]:
"""
执行一轮 ping 检测:
1. 加载所有启用设备
2. 批量 fping
3. 记录结果
4. 更新设备状态
"""
self._round_num += 1
round_num = self._round_num
# 1. 加载启用设备
result = await db.execute(
select(Device).where(Device.is_enabled == True)
)
devices = list(result.scalars().all())
if not devices:
logger.info(f"[Round {round_num}] 没有启用的设备")
return []
logger.info(f"[Round {round_num}] 开始检测 {len(devices)} 台设备")
# 2. 批量 ping
start_time = time.time()
ip_to_device = {d.ip: d for d in devices}
ip_list = list(ip_to_device.keys())
ping_results_map = await self._batch_ping(ip_list)
# 3. 构造结果
results: list[PingResult] = []
for ip, dev in ip_to_device.items():
is_alive, rtt = ping_results_map.get(ip, (False, None))
results.append(PingResult(device_id=dev.id, is_alive=is_alive, response_time_ms=rtt))
elapsed = time.time() - start_time
alive_count = sum(1 for r in results if r.is_alive)
# 4. 批量写入 ping_records
now = datetime.now()
records = [
PingRecord(
device_id=r.device_id,
is_alive=r.is_alive,
response_time_ms=r.response_time_ms,
round_num=round_num,
created_at=now,
)
for r in results
]
db.add_all(records)
await db.flush()
# 5. 更新设备状态(状态机)
device_map = {d.id: d for d in devices}
for r in results:
dev = device_map.get(r.device_id)
if not dev:
continue
old_status = dev.current_status
if r.is_alive:
dev.consecutive_failures = 0
dev.last_ping_time = now
dev.last_online_time = now
dev.current_status = "online"
else:
dev.consecutive_failures = (dev.consecutive_failures or 0) + 1
dev.last_ping_time = now
if dev.consecutive_failures >= dev.alert_threshold:
if dev.current_status != "offline":
dev.current_status = "offline"
dev.last_offline_time = now
else:
if dev.current_status == "online":
dev.current_status = "checking"
# 状态变化回调
if old_status != dev.current_status and self._on_state_change:
change = DeviceStateChange(
device=dev,
old_status=old_status,
new_status=dev.current_status,
consecutive_failures=dev.consecutive_failures,
)
await self._on_state_change(change)
await db.commit()
logger.info(
f"[Round {round_num}] 完成: {alive_count}/{len(devices)} 在线, "
f"耗时 {elapsed:.2f}s"
)
return results
async def _batch_ping(self, ip_list: list[str]) -> dict[str, tuple[bool, Optional[float]]]:
"""
使用 fping 批量 ping
返回: { ip: (is_alive, response_time_ms) }
"""
if not ip_list:
return {}
try:
# fping 一次性 ping 多个 IP
# -c 1: 每个 IP 发 1 个包
# -t: 超时毫秒
timeout_ms = int(settings.PING_TIMEOUT_SECONDS * 1000)
cmd = [
settings.FPING_PATH,
"-c", "1",
"-t", str(timeout_ms),
"-e", # 显示响应时间
] + ip_list
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
result_map: dict[str, tuple[bool, Optional[float]]] = {}
# fping 标准输出逐行: "IP : xmt/rcv/%loss = 1/1/0%, rtt min/avg/max = 0.12/0.12/0.12"
# 或 "IP : xmt/rcv/%loss = 1/0/100%"
for line in stdout.decode("utf-8", errors="replace").splitlines():
line = line.strip()
if ":" not in line:
continue
ip = line.split(":")[0].strip()
# 解析响应时间
if "rtt" in line:
try:
# 提取 avg rtt
rtt_part = line.split("rtt")[1]
# 格式: min/avg/max = 0.12/0.12/0.12
if "=" in rtt_part:
avg_rtt_str = rtt_part.split("=")[1].strip().split("/")[1]
rtt_ms = float(avg_rtt_str)
else:
rtt_ms = None
except (IndexError, ValueError):
rtt_ms = None
result_map[ip] = (True, rtt_ms)
else:
result_map[ip] = (False, None)
return result_map
except FileNotFoundError:
logger.warning("fping 未找到,回退到系统 ping (串行)")
return await self._fallback_ping(ip_list)
except Exception as e:
logger.error(f"fping 异常: {e}")
return await self._fallback_ping(ip_list)
async def _fallback_ping(self, ip_list: list[str]) -> dict[str, tuple[bool, Optional[float]]]:
"""回退方案:使用系统 ping,并发执行"""
async def ping_one(ip: str) -> tuple[str, bool, Optional[float]]:
try:
timeout = settings.PING_TIMEOUT_SECONDS
cmd = ["ping", "-c", "1", "-W", str(int(timeout)), ip]
start = time.time()
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
await proc.wait()
elapsed = (time.time() - start) * 1000
return ip, proc.returncode == 0, round(elapsed, 2)
except Exception:
return ip, False, None
tasks = [ping_one(ip) for ip in ip_list]
sem = asyncio.Semaphore(settings.PING_CONCURRENCY)
async def bounded_ping(ip: str):
async with sem:
return await ping_one(ip)
results = await asyncio.gather(*[bounded_ping(ip) for ip in ip_list])
return {ip: (alive, rtt) for ip, alive, rtt in results}
+94
View File
@@ -0,0 +1,94 @@
"""
定时任务调度器
使用 asyncio 循环驱动 Ping 引擎,协调 Pinger 和 Alerter。
"""
import asyncio
import logging
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.pinger import Pinger
from app.services.alerter import Alerter
from app.core.deps import async_session
logger = logging.getLogger("pingwatch.scheduler")
class PingScheduler:
"""
调度器职责:
1. 按间隔驱动 Ping 引擎
2. 每轮结束后触发 Alerter 处理待发送告警
3. 控制并发和清理
"""
def __init__(self):
self._pinger = Pinger()
self._alerter = Alerter()
self._running = False
self._task: asyncio.Task | None = None
# 注册状态变化回调
self._pinger.on_state_change(self._on_state_change)
async def _on_state_change(self, change):
"""收到设备状态变化,转给 alerter"""
async with async_session() as db:
try:
await self._alerter.on_state_change(change, db)
except Exception as e:
logger.error(f"告警处理异常: {e}", exc_info=True)
async def _run_loop(self):
"""主循环"""
logger.info("Ping 调度器已启动")
self._running = True
while self._running:
try:
async with async_session() as db:
# 执行一轮 ping
results = await self._pinger.run_one_round(db)
if results:
# 直接用启用的设备数
total = len(results)
# 处理待发送告警
await self._alerter.flush_pending(db, total)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"调度器异常: {e}", exc_info=True)
# 等待下一轮
await asyncio.sleep(settings.PING_INTERVAL_SECONDS)
logger.info("Ping 调度器已停止")
def start(self):
"""启动调度器(后台任务)"""
if self._running:
logger.warning("调度器已在运行")
return
self._task = asyncio.create_task(self._run_loop())
async def stop(self):
"""停止调度器"""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
# 全局调度器实例
scheduler = PingScheduler()