848f804169
- FastAPI 后端 + Vue 3 前端 - Docker Compose 一键部署 - Casdoor OAuth 认证集成 - LogHive 集中式日志 - 设备批量 CSV 导入/导出 - WebSocket 实时状态推送 - 企业微信告警通知 - fping 高性能并发 Ping 检测 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
243 lines
8.5 KiB
Python
243 lines
8.5 KiB
Python
"""
|
|
异步 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}
|