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
+1
View File
@@ -0,0 +1 @@
from . import devices, alerts, stats, auth, ws, users
+133
View File
@@ -0,0 +1,133 @@
"""告警记录 API"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db
from app.core.auth import get_current_user
from app.models.device import Device
from app.models.alert_event import AlertEvent, AlertTypeEnum
from app.models.user import User
from app.schemas.alert import AlertEventOut
router = APIRouter(prefix="/api/alerts", tags=["告警记录"])
@router.get("", response_model=dict)
async def list_alerts(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
device_id: Optional[int] = None,
alert_type: Optional[str] = None,
is_resolved: Optional[bool] = None,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""查询告警记录,支持分页和过滤"""
query = select(AlertEvent)
count_query = select(func.count(AlertEvent.id))
# 过滤条件
if device_id:
query = query.where(AlertEvent.device_id == device_id)
count_query = count_query.where(AlertEvent.device_id == device_id)
if alert_type:
query = query.where(AlertEvent.alert_type == alert_type)
count_query = count_query.where(AlertEvent.alert_type == alert_type)
if is_resolved is not None:
query = query.where(AlertEvent.is_resolved == is_resolved)
count_query = count_query.where(AlertEvent.is_resolved == is_resolved)
if start_time:
query = query.where(AlertEvent.created_at >= start_time)
count_query = count_query.where(AlertEvent.created_at >= start_time)
if end_time:
query = query.where(AlertEvent.created_at <= end_time)
count_query = count_query.where(AlertEvent.created_at <= end_time)
# 总数
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# 分页
offset = (page - 1) * page_size
query = query.order_by(AlertEvent.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
events = list(result.scalars().all())
# 关联设备信息
device_ids = {e.device_id for e in events if e.device_id > 0}
devices_map = {}
if device_ids:
dev_result = await db.execute(select(Device).where(Device.id.in_(device_ids)))
devices_map = {d.id: d for d in dev_result.scalars().all()}
items = []
for e in events:
dev = devices_map.get(e.device_id)
items.append(AlertEventOut(
id=e.id,
device_id=e.device_id,
device_name=dev.name if dev else "系统",
device_ip=dev.ip if dev else "",
device_type=dev.device_type.value if dev else "",
location=dev.location if dev else "",
project_name=dev.project_name if dev else "",
alert_type=e.alert_type.value,
message=e.message,
start_at=e.start_at,
end_at=e.end_at,
duration_minutes=e.duration_minutes,
is_resolved=e.is_resolved,
notification_sent=e.notification_sent,
created_at=e.created_at,
))
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.get("/latest", response_model=list[AlertEventOut])
async def get_latest_alerts(
limit: int = Query(10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取最近的告警"""
result = await db.execute(
select(AlertEvent)
.order_by(AlertEvent.created_at.desc())
.limit(limit)
)
events = list(result.scalars().all())
device_ids = {e.device_id for e in events if e.device_id > 0}
devices_map = {}
if device_ids:
dev_result = await db.execute(select(Device).where(Device.id.in_(device_ids)))
devices_map = {d.id: d for d in dev_result.scalars().all()}
items = []
for e in events:
dev = devices_map.get(e.device_id)
items.append(AlertEventOut(
id=e.id,
device_id=e.device_id,
device_name=dev.name if dev else "系统",
device_ip=dev.ip if dev else "",
device_type=dev.device_type.value if dev else "",
location=dev.location if dev else "",
project_name=dev.project_name if dev else "",
alert_type=e.alert_type.value,
message=e.message,
start_at=e.start_at,
end_at=e.end_at,
duration_minutes=e.duration_minutes,
is_resolved=e.is_resolved,
notification_sent=e.notification_sent,
created_at=e.created_at,
))
return items
+88
View File
@@ -0,0 +1,88 @@
"""Casdoor 认证 API"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.core.deps import get_db
from app.core.auth import create_access_token, exchange_code_for_user, get_current_user
from app.models.user import User, UserRoleEnum
router = APIRouter(prefix="/api/auth", tags=["认证"])
class LoginRequest(BaseModel):
code: str # Casdoor 返回的 OAuth code
class LoginResponse(BaseModel):
token: str
user: dict
@router.post("/login", response_model=LoginResponse)
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
"""用 Casdoor OAuth code 登录"""
casdoor_user = await exchange_code_for_user(data.code)
if not casdoor_user:
raise HTTPException(status_code=401, detail="Casdoor 认证失败")
# 从 id_token 中提取用户信息
# id_token payload 一般包含: sub, name, preferred_username, email, displayName 等
casdoor_uid = casdoor_user.get("sub") or casdoor_user.get("name", "")
username = casdoor_user.get("preferred_username") or casdoor_user.get("name", casdoor_uid)
display_name = casdoor_user.get("displayName", "")
if not casdoor_uid:
# 尝试用 name 作为备用标识
casdoor_uid = casdoor_user.get("name", "")
if not casdoor_uid:
raise HTTPException(status_code=401, detail="无法从 Casdoor 获取用户标识")
# 查找或创建本地用户
result = await db.execute(select(User).where(User.casdoor_uid == casdoor_uid))
user = result.scalar_one_or_none()
if not user:
# 首次登录,自动创建 viewer 账号
user = User(
casdoor_uid=casdoor_uid,
username=username,
display_name=display_name or username,
role=UserRoleEnum.viewer,
)
db.add(user)
await db.commit()
await db.refresh(user)
logger.info(f"新用户自动创建: {username} (uid: {casdoor_uid})")
# 更新最后登录时间
user.last_login_at = datetime.now()
await db.commit()
# 签发 PingWatch JWT
token = create_access_token(
data={"sub": user.casdoor_uid, "role": user.role.value}
)
return LoginResponse(
token=token,
user={
"id": user.id,
"username": user.username,
"display_name": user.display_name,
"role": user.role.value,
},
)
@router.get("/me")
async def get_me(current_user: User = Depends(get_current_user)):
"""获取当前登录用户信息"""
return {
"id": current_user.id,
"username": current_user.username,
"display_name": current_user.display_name,
"role": current_user.role.value,
}
+209
View File
@@ -0,0 +1,209 @@
"""设备管理 API"""
import csv
import io
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db
from app.core.auth import get_current_user, require_admin
from app.models.device import Device, DeviceTypeEnum
from app.models.user import User
from app.schemas.device import DeviceCreate, DeviceUpdate, DeviceOut
logger = logging.getLogger("pingwatch.devices")
router = APIRouter(prefix="/api/devices", tags=["设备管理"])
# CSV 模板列
IMPORT_COLUMNS = [
("name", "设备名称(必填)"),
("ip", "IP地址(必填)"),
("device_type", "设备类型(server/olt/switch/firewall/other)"),
("location", "物理位置"),
("project_name", "所属项目"),
("tags", "标签(逗号分隔)"),
("ping_interval", "Ping间隔(秒)"),
("alert_threshold", "告警阈值(次)"),
]
@router.get("", response_model=list[DeviceOut])
async def list_devices(
is_enabled: Optional[bool] = None,
device_type: Optional[str] = None,
search: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取设备列表,支持过滤和搜索"""
query = select(Device)
if is_enabled is not None:
query = query.where(Device.is_enabled == is_enabled)
if device_type:
query = query.where(Device.device_type == device_type)
if search:
like = f"%{search}%"
query = query.where(
Device.name.ilike(like) | Device.ip.ilike(like) | Device.location.ilike(like)
)
query = query.order_by(Device.id)
result = await db.execute(query)
return list(result.scalars().all())
@router.get("/{device_id}", response_model=DeviceOut)
async def get_device(
device_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Device).where(Device.id == device_id))
device = result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
return device
@router.post("", response_model=DeviceOut)
async def create_device(
data: DeviceCreate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_admin),
):
device = Device(**data.model_dump())
db.add(device)
await db.commit()
await db.refresh(device)
return device
@router.put("/{device_id}", response_model=DeviceOut)
async def update_device(
device_id: int,
data: DeviceUpdate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_admin),
):
result = await db.execute(select(Device).where(Device.id == device_id))
device = result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
update_data = data.model_dump(exclude_unset=True)
if update_data:
update_data["updated_at"] = datetime.now()
await db.execute(update(Device).where(Device.id == device_id).values(**update_data))
await db.commit()
await db.refresh(device)
return device
@router.delete("/{device_id}")
async def delete_device(
device_id: int,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_admin),
):
result = await db.execute(select(Device).where(Device.id == device_id))
device = result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
await db.delete(device)
await db.commit()
return {"message": "删除成功"}
@router.get("/template/download")
async def download_template(
admin: User = Depends(require_admin),
):
"""下载 CSV 导入模板"""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([col[1] for col in IMPORT_COLUMNS])
# 写入一行示例数据
writer.writerow([
"示例设备", "192.168.1.1", "server", "机房A", "项目1", "核心,生产", "30", "5"
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8-sig",
headers={"Content-Disposition": "attachment; filename=device_template.csv"},
)
@router.post("/import")
async def import_devices(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_admin),
):
"""批量导入设备(CSV"""
if not file.filename or not file.filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="请上传 .csv 文件")
content = await file.read()
# 处理 BOM
text = content.decode("utf-8-sig")
reader = csv.reader(io.StringIO(text))
rows = list(reader)
if len(rows) < 2:
raise HTTPException(status_code=400, detail="CSV 至少需要表头行 + 一行数据")
devices_added = 0
errors = []
for i, row in enumerate(rows[1:], start=1):
if not any(cell.strip() for cell in row):
continue # 跳过空行
if len(row) < 2:
errors.append(f"{i + 1} 行: 缺少必填列")
continue
name = row[0].strip() if len(row) > 0 else ""
ip = row[1].strip() if len(row) > 1 else ""
if not name or not ip:
errors.append(f"{i + 1} 行: 设备名称和 IP 地址为必填")
continue
# 检查 IP 是否重复
existing = await db.execute(select(Device).where(Device.ip == ip))
if existing.scalar_one_or_none():
errors.append(f"{i + 1} 行: IP {ip} 已存在")
continue
device_type = row[2].strip().lower() if len(row) > 2 and row[2].strip() else "other"
if device_type not in (e.value for e in DeviceTypeEnum):
device_type = "other"
device = Device(
name=name,
ip=ip,
device_type=device_type,
location=row[3].strip() if len(row) > 3 else "",
project_name=row[4].strip() if len(row) > 4 else "",
tags=row[5].strip() if len(row) > 5 else "",
ping_interval=int(row[6]) if len(row) > 6 and row[6].strip().isdigit() else 30,
alert_threshold=int(row[7]) if len(row) > 7 and row[7].strip().isdigit() else 5,
)
db.add(device)
devices_added += 1
await db.commit()
result = {"devices_added": devices_added}
if errors:
result["errors"] = errors
logger.info(f"批量导入完成: 成功 {devices_added} 台, 错误 {len(errors)}")
return result
+183
View File
@@ -0,0 +1,183 @@
"""统计 API"""
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func, and_, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db
from app.core.auth import get_current_user
from app.models.device import Device
from app.models.ping_record import PingRecord
from app.models.alert_event import AlertEvent, AlertTypeEnum
from app.models.user import User
from app.schemas.stats import DeviceStatusSummary, DeviceStatsItem, TimeSeriesPoint, DashboardStats
router = APIRouter(prefix="/api/stats", tags=["统计"])
@router.get("/summary", response_model=DeviceStatusSummary)
async def get_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取当前设备状态汇总"""
result = await db.execute(select(Device))
devices = list(result.scalars().all())
total = len(devices)
online = sum(1 for d in devices if d.current_status == "online")
offline = sum(1 for d in devices if d.current_status == "offline")
checking = sum(1 for d in devices if d.current_status == "checking")
unknown = sum(1 for d in devices if d.current_status == "unknown")
online_rate = round(online / max(total, 1) * 100, 2)
return DeviceStatusSummary(
total=total, online=online, offline=offline,
checking=checking, unknown=unknown, online_rate=online_rate,
)
@router.get("/offline-trend", response_model=list[TimeSeriesPoint])
async def get_offline_trend(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取指定天数内的每日离线次数趋势"""
start = datetime.now() - timedelta(days=days)
result = await db.execute(
select(
func.date(AlertEvent.start_at).label("day"),
func.count(AlertEvent.id).label("count"),
)
.where(AlertEvent.alert_type == AlertTypeEnum.offline)
.where(AlertEvent.start_at >= start)
.group_by(func.date(AlertEvent.start_at))
.order_by("day")
)
rows = result.all()
return [TimeSeriesPoint(time=datetime.strptime(r.day, "%Y-%m-%d"), value=r.count) for r in rows]
@router.get("/online-rate-trend", response_model=list[TimeSeriesPoint])
async def get_online_rate_trend(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取每日在线率趋势"""
start = datetime.now() - timedelta(days=days)
# 按天统计每轮的存活/总数比例
result = await db.execute(
select(
func.date(PingRecord.created_at).label("day"),
func.round(
func.sum(case((PingRecord.is_alive == True, 1), else_=0)) /
func.count(PingRecord.id) * 100, 2
).label("rate"),
)
.where(PingRecord.created_at >= start)
.group_by(func.date(PingRecord.created_at))
.order_by("day")
)
rows = result.all()
return [TimeSeriesPoint(time=datetime.strptime(r.day, "%Y-%m-%d"), value=r.rate) for r in rows]
@router.get("/packet-loss-top", response_model=list[DeviceStatsItem])
async def get_packet_loss_top(
limit: int = Query(10, ge=1, le=50),
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取丢包率最高的设备排名"""
start = datetime.now() - timedelta(days=days)
subq = (
select(
PingRecord.device_id,
func.count(PingRecord.id).label("total_pings"),
func.sum(case((PingRecord.is_alive == True, 1), else_=0)).label("alive_pings"),
func.avg(PingRecord.response_time_ms).label("avg_rtt"),
)
.where(PingRecord.created_at >= start)
.group_by(PingRecord.device_id)
.subquery()
)
result = await db.execute(
select(
subq.c.device_id,
subq.c.total_pings,
subq.c.alive_pings,
subq.c.avg_rtt,
Device.name,
Device.ip,
Device.device_type,
Device.location,
Device.project_name,
)
.join(Device, subq.c.device_id == Device.id)
.order_by(
(subq.c.total_pings - subq.c.alive_pings) * 1.0 / subq.c.total_pings
)
.limit(limit)
)
rows = result.all()
items = []
for r in rows:
loss_rate = round((r.total_pings - r.alive_pings) / max(r.total_pings, 1) * 100, 2)
# 离线次数和总时长
alert_result = await db.execute(
select(
func.count(AlertEvent.id),
func.coalesce(func.sum(AlertEvent.duration_minutes), 0),
func.coalesce(func.max(AlertEvent.duration_minutes), 0),
)
.where(AlertEvent.device_id == r.device_id)
.where(AlertEvent.alert_type == AlertTypeEnum.offline)
.where(AlertEvent.start_at >= start)
)
cnt, total_dur, max_dur = alert_result.one()
items.append(DeviceStatsItem(
device_id=r.device_id,
device_name=r.name,
device_ip=r.ip,
device_type=r.device_type.value if hasattr(r.device_type, 'value') else str(r.device_type),
location=r.location or "",
project_name=r.project_name or "",
total_pings=r.total_pings,
alive_pings=r.alive_pings,
packet_loss_rate=loss_rate,
avg_response_time=round(r.avg_rtt, 2) if r.avg_rtt else None,
offline_count=cnt,
total_offline_duration=total_dur,
max_offline_duration=max_dur,
))
return items
@router.get("/dashboard", response_model=DashboardStats)
async def get_dashboard(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""仪表盘聚合数据"""
summary = await get_summary(db, current_user)
recent = await get_latest_alerts(limit=10, db=db, current_user=current_user)
trend = await get_offline_trend(7, db, current_user)
top_loss = await get_packet_loss_top(10, 7, db, current_user)
return DashboardStats(
summary=summary, recent_offline=recent,
offline_trend=trend, packet_loss_top=top_loss,
)
# 复用上面定义的函数
from app.api.alerts import get_latest_alerts
+57
View File
@@ -0,0 +1,57 @@
"""用户管理 API(管理员)"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db
from app.core.auth import get_current_user, require_admin
from app.models.user import User, UserRoleEnum
router = APIRouter(prefix="/api/users", tags=["用户管理"])
@router.get("")
async def list_users(
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_admin),
):
"""获取所有用户"""
result = await db.execute(select(User).order_by(User.id))
users = result.scalars().all()
return [
{
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"role": u.role.value,
"is_active": u.is_active,
"last_login_at": u.last_login_at,
"created_at": u.created_at,
}
for u in users
]
class UpdateUserRoleRequest(BaseModel):
role: str # "admin" or "viewer"
@router.put("/{user_id}/role")
async def update_user_role(
user_id: int,
data: UpdateUserRoleRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_admin),
):
"""更新用户角色"""
if data.role not in ("admin", "viewer"):
raise HTTPException(status_code=400, detail="无效角色")
if user_id == admin.id:
raise HTTPException(status_code=400, detail="不能修改自己的角色")
new_role = UserRoleEnum.admin if data.role == "admin" else UserRoleEnum.viewer
await db.execute(update(User).where(User.id == user_id).values(role=new_role))
await db.commit()
return {"message": "更新成功"}
+70
View File
@@ -0,0 +1,70 @@
"""WebSocket 实时推送"""
import json
import asyncio
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
logger = logging.getLogger("pingwatch.ws")
router = APIRouter()
class ConnectionManager:
"""WebSocket 连接管理器"""
def __init__(self):
self._connections: set[WebSocket] = set()
async def connect(self, ws: WebSocket):
await ws.accept()
self._connections.add(ws)
def disconnect(self, ws: WebSocket):
self._connections.discard(ws)
async def broadcast(self, message: dict):
"""向所有客户端广播消息"""
dead = set()
for ws in self._connections:
try:
await ws.send_json(message)
except Exception:
dead.add(ws)
self._connections -= dead
@property
def count(self) -> int:
return len(self._connections)
manager = ConnectionManager()
@router.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
"""WebSocket 端点,用于前端实时接收状态更新"""
await manager.connect(ws)
try:
while True:
# 保持连接,接收心跳 pong
data = await ws.receive_text()
if data == "ping":
await ws.send_text("pong")
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"WebSocket 异常: {e}")
finally:
manager.disconnect(ws)
async def broadcast_status_change(device_id: int, status: str, name: str):
"""广播设备状态变化"""
await manager.broadcast({
"type": "device_status_change",
"device_id": device_id,
"status": status,
"name": name,
"timestamp": asyncio.get_event_loop().time(),
})