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
+9
View File
@@ -0,0 +1,9 @@
__pycache__
*.pyc
.venv
venv
node_modules
dist
.git
.env
**/__pycache__
+28
View File
@@ -0,0 +1,28 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Node
node_modules/
dist/
# IDE
.idea/
.vscode/
*.swp
# Env
.env
backend/.env
# Database
*.db
*.sqlite3
# OS
.DS_Store
Thumbs.db
.claude/
+35
View File
@@ -0,0 +1,35 @@
# 数据库(开发用 SQLite,生产用 PostgreSQL
DATABASE_URL=sqlite+aiosqlite:///./pingwatch.db
# DATABASE_URL=postgresql+asyncpg://user:pass@localhost/pingwatch
# 企业微信
WECOM_CORP_ID=your_corp_id
WECOM_AGENT_ID=1000001
WECOM_APP_SECRET=your_app_secret
# Casdoor
CASDOOR_ENDPOINT=https://casdoor.dhdx.fun
CASDOOR_CLIENT_ID=e46b9e1eb893027bdf2a
CASDOOR_CLIENT_SECRET=b12c7e1688ed51481f3b5c5dae4191b6edbba916
CASDOOR_CERTIFICATE=
CASDOOR_ORGANIZATION=dahua
CASDOOR_APPLICATION=PingWatch
CASDOOR_REDIRECT_URI=http://10.10.10.7:5173/login
# Ping 引擎
PING_INTERVAL_SECONDS=30
PING_TIMEOUT_SECONDS=5.0
DEFAULT_ALERT_THRESHOLD=5
PING_CONCURRENCY=50
# 上游心跳
UPSTREAM_PING_TARGET=114.114.114.114
UPSTREAM_PING_THRESHOLD=2
OFFLINE_SUPPRESS_RATIO=0.9
# 数据保留
PING_RECORD_RETENTION_DAYS=90
ALERT_RETENTION_DAYS=365
# JWT 密钥(请改成随机字符串)
SECRET_KEY=change-me-to-a-long-random-string
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
fping iputils-ping && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
View File
+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(),
})
+57
View File
@@ -0,0 +1,57 @@
"""应用配置,通过环境变量注入,不支持 .env 文件"""
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# ---------- 数据库 ----------
DATABASE_URL: str = "sqlite+aiosqlite:///./pingwatch.db"
# PostgreSQL: "postgresql+asyncpg://user:pass@localhost/pingwatch"
# ---------- 企业微信 ----------
WECOM_CORP_ID: str = ""
WECOM_AGENT_ID: int = 0
WECOM_APP_SECRET: str = ""
# ---------- Casdoor ----------
CASDOOR_ENDPOINT: str = "https://casdoor.dhdx.fun"
CASDOOR_CLIENT_ID: str = ""
CASDOOR_CLIENT_SECRET: str = ""
CASDOOR_CERTIFICATE: str = "" # 可选,用于验证 id_token 签名
CASDOOR_ORGANIZATION: str = "dahua"
CASDOOR_APPLICATION: str = "PingWatch"
CASDOOR_REDIRECT_URI: str = "http://10.10.10.7:5173/login" # 前端回调地址
# ---------- Ping 引擎 ----------
PING_INTERVAL_SECONDS: int = 30
PING_TIMEOUT_SECONDS: float = 5.0
DEFAULT_ALERT_THRESHOLD: int = 5
PING_CONCURRENCY: int = 50
FPING_PATH: str = "/usr/bin/fping"
# ---------- 上游心跳 ----------
UPSTREAM_PING_TARGET: str = "114.114.114.114"
UPSTREAM_PING_THRESHOLD: int = 2
OFFLINE_SUPPRESS_RATIO: float = 0.9
# ---------- 数据保留 ----------
PING_RECORD_RETENTION_DAYS: int = 90
ALERT_RETENTION_DAYS: int = 365
# ---------- JWT ----------
SECRET_KEY: str = "change-me-to-a-long-random-string"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
# ---------- LogHive ----------
LOGHIVE_ENDPOINT: str = "http://10.10.10.14:8000"
LOGHIVE_PROJECT: str = "PingWatch"
LOGHIVE_API_KEY: str = ""
# ---------- CORS ----------
CORS_ORIGINS: str = "http://localhost:5173,http://10.10.10.7:5173,http://10.10.10.7"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
View File
+128
View File
@@ -0,0 +1,128 @@
"""
Casdoor OAuth 认证集成 + JWT 会话管理
流程:
1. 前端跳转到 Casdoor 登录页 → 用户登录
2. Casdoor 回调到前端(带 code 参数)
3. 前端将 code 发到后端 /api/auth/login
4. 后端用 code 向 Casdoor 换取 access_token + id_token
5. 后端从 id_token (JWT) 解析用户信息
6. 后端签发自己的 JWT,返回给前端
"""
import logging
import os
from datetime import datetime, timedelta
from typing import Optional
import httpx
from jose import JWTError, jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.user import User, UserRoleEnum
from app.core.deps import get_db
logger = logging.getLogger("pingwatch.auth")
security = HTTPBearer()
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""签发 PingWatch 自己的 JWT"""
to_encode = data.copy()
expire = datetime.now() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
def _load_casdoor_certificate() -> str:
"""加载 Casdoor 证书:优先读取 dahua.pem,否则用环境变量"""
pem_path = os.path.join(os.path.dirname(__file__), "..", "..", "dahua.pem")
pem_path = os.path.normpath(pem_path)
if os.path.isfile(pem_path):
with open(pem_path, "r") as f:
return f.read()
return settings.CASDOOR_CERTIFICATE
async def exchange_code_for_user(code: str) -> Optional[dict]:
"""
用 OAuth code 向 Casdoor 换取用户信息。
步骤:
1. POST → /api/login/oauth/access_token 换取 id_token
2. 解码 id_token (JWT) 得到用户信息
"""
token_url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
data = {
"grant_type": "authorization_code",
"client_id": settings.CASDOOR_CLIENT_ID,
"client_secret": settings.CASDOOR_CLIENT_SECRET,
"code": code,
}
async with httpx.AsyncClient(timeout=15, verify=False) as client:
try:
resp = await client.post(token_url, data=data)
if resp.status_code != 200:
logger.error(f"Casdoor token 换取失败: {resp.status_code} {resp.text}")
return None
token_data = resp.json()
id_token = token_data.get("id_token")
if not id_token:
logger.error("Casdoor 返回中没有 id_token")
return None
# 解码 id_token (JWT) payload,不验证签名(HTTPS 已保证传输安全)
# 生产环境建议验证 Casdoor 证书
cert = _load_casdoor_certificate()
try:
payload = jwt.decode(
id_token,
key=cert or None,
options={"verify_signature": bool(cert)},
audience=settings.CASDOOR_CLIENT_ID,
)
except JWTError:
# 不验证签名的方式解码
payload = jwt.get_unverified_claims(id_token)
return payload
except httpx.TimeoutException:
logger.error("Casdoor token 请求超时")
return None
except Exception as e:
logger.error(f"Casdoor token 请求异常: {e}", exc_info=True)
return None
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""从 PingWatch JWT 中解析当前登录用户"""
token = credentials.credentials
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
casdoor_uid: str = payload.get("sub", "")
if not casdoor_uid:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效 token")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效 token")
result = await db.execute(select(User).where(User.casdoor_uid == casdoor_uid))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用")
return user
async def require_admin(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != UserRoleEnum.admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可执行此操作")
return current_user
+28
View File
@@ -0,0 +1,28 @@
"""数据库会话依赖"""
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.config import settings
# 处理 sqlite 协议兼容
db_url = settings.DATABASE_URL
if db_url.startswith("sqlite"):
db_url = db_url.replace("sqlite://", "sqlite+aiosqlite://")
engine = create_async_engine(db_url, echo=False, pool_pre_ping=True)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
try:
yield session
finally:
await session.close()
async def init_db():
"""创建所有表"""
from app.models.device import Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+137
View File
@@ -0,0 +1,137 @@
"""
LogHive 日志 Handler
基于标准 logging.Handler,通过 REST API 将日志异步批量发送到 LogHive。
不依赖外部包,后台线程发送,失败不影响主业务。
"""
import asyncio
import atexit
import json
import logging
import queue
import threading
import time
import traceback as tb
import httpx
class LogHiveHandler(logging.Handler):
"""异步批量发送日志到 LogHive"""
def __init__(
self,
endpoint: str,
project: str,
api_key: str,
level: int = logging.INFO,
batch_size: int = 50,
flush_interval: float = 2.0,
max_retries: int = 3,
):
super().__init__(level=level)
self._endpoint = endpoint.rstrip("/") + "/api/logs/ingest"
self._project = project
self._api_key = api_key
self._batch_size = batch_size
self._flush_interval = flush_interval
self._max_retries = max_retries
self._queue: queue.Queue = queue.Queue()
self._client: httpx.Client | None = None
self._thread: threading.Thread | None = None
self._running = False
def _ensure_client(self):
if self._client is None:
self._client = httpx.Client(timeout=10)
def _ensure_thread(self):
if self._thread is None or not self._thread.is_alive():
self._running = True
self._thread = threading.Thread(target=self._send_loop, daemon=True)
self._thread.start()
def emit(self, record: logging.LogRecord):
"""接收日志记录,放入队列"""
if not self._api_key:
return
self._ensure_thread()
try:
entry = {
"level": record.levelname.lower(),
"message": self.format(record),
"logger": record.name,
"timestamp": record.created,
}
if record.exc_info and record.exc_info[1]:
entry["exception"] = "".join(
tb.format_exception(*record.exc_info)
)
self._queue.put_nowait(entry)
except Exception:
pass # 日志发送失败不能影响主业务
def _send_loop(self):
"""后台线程:定时批量发送"""
while self._running:
batch = []
deadline = time.monotonic() + self._flush_interval
while len(batch) < self._batch_size:
try:
remaining = max(0, deadline - time.monotonic())
batch.append(self._queue.get(timeout=remaining))
except queue.Empty:
break
if batch:
payload = {"project": self._project, "entries": batch}
for attempt in range(self._max_retries):
try:
self._ensure_client()
resp = self._client.post(
self._endpoint,
json=payload,
headers={"Authorization": f"Bearer {self._api_key}"},
)
if resp.status_code < 500:
break
except Exception:
if attempt == self._max_retries - 1:
pass # 最终丢弃
else:
time.sleep(0.5 * (attempt + 1))
def close(self):
"""关闭 handlerflush 剩余日志"""
self._running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5)
if self._client:
self._client.close()
super().close()
class AsyncLogHiveHandler:
"""
用于 asyncio 事件循环的异步 handler。
在独立的线程中运行同步 LogHiveHandler,通过 asyncio 队列桥接。
"""
def __init__(self, **kwargs):
self._handler = LogHiveHandler(**kwargs)
self._loop: asyncio.AbstractEventLoop | None = None
def setup(self, loop: asyncio.AbstractEventLoop):
self._loop = loop
atexit.register(self._handler.close)
async def emit(self, record: logging.LogRecord):
"""异步安全地提交日志记录"""
# LogHiveHandler.emit 已经把日志放入内部队列,这里只需确保线程运行
self._handler.emit(record)
def close(self):
self._handler.close()
+99
View File
@@ -0,0 +1,99 @@
"""
PingWatch — 网络设备离线监控系统
FastAPI 后端入口
"""
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.core.deps import init_db
from app.core.loghive import LogHiveHandler
from app.services.scheduler import scheduler
from app.services.cleanup import cleanup_old_data
from app.api import devices, alerts, stats, auth, ws, users
# 日志配置
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
# 接入 LogHive 日志系统
loghive_handler = LogHiveHandler(
endpoint=settings.LOGHIVE_ENDPOINT,
project=settings.LOGHIVE_PROJECT,
api_key=settings.LOGHIVE_API_KEY,
)
if settings.LOGHIVE_API_KEY:
logging.getLogger().addHandler(loghive_handler)
logger = logging.getLogger("pingwatch")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期"""
logger.info("PingWatch 启动中...")
# 初始化数据库
await init_db()
logger.info("数据库初始化完成")
# 启动 Ping 调度器
scheduler.start()
logger.info("Ping 调度器已启动")
# 启动定时清理任务
async def cleanup_loop():
while True:
await asyncio.sleep(3600 * 6) # 每 6 小时
try:
await cleanup_old_data()
except Exception as e:
logger.error(f"清理任务异常: {e}")
cleanup_task = asyncio.create_task(cleanup_loop())
yield
# 关闭
await scheduler.stop()
cleanup_task.cancel()
loghive_handler.close()
logger.info("PingWatch 已关闭")
app = FastAPI(
title="PingWatch",
description="网络设备离线监控系统",
version="1.0.0",
lifespan=lifespan,
)
# CORS
origins = [o.strip() for o in settings.CORS_ORIGINS.split(",")]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(auth.router)
app.include_router(devices.router)
app.include_router(alerts.router)
app.include_router(stats.router)
app.include_router(ws.router)
app.include_router(users.router)
@app.get("/api/health")
async def health():
return {"status": "ok"}
+11
View File
@@ -0,0 +1,11 @@
from .device import Device, DeviceTypeEnum
from .ping_record import PingRecord
from .alert_event import AlertEvent, AlertTypeEnum
from .user import User, UserRoleEnum
__all__ = [
"Device", "DeviceTypeEnum",
"PingRecord",
"AlertEvent", "AlertTypeEnum",
"User", "UserRoleEnum",
]
+34
View File
@@ -0,0 +1,34 @@
import enum
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, BigInteger, Enum
from .device import Base
class AlertTypeEnum(str, enum.Enum):
offline = "offline" # 设备离线
recovered = "recovered" # 设备恢复
system = "system" # 系统告警(如上游断网检测)
class AlertEvent(Base):
"""告警事件表"""
__tablename__ = "alert_events"
id = Column(BigInteger, primary_key=True, autoincrement=True)
device_id = Column(Integer, nullable=False, index=True, comment="关联设备 ID")
alert_type = Column(Enum(AlertTypeEnum), nullable=False, comment="告警类型")
message = Column(String(1024), default="", comment="告警消息摘要")
# 离线起止
start_at = Column(DateTime, nullable=False, comment="离线开始时间")
end_at = Column(DateTime, nullable=True, comment="恢复时间")
duration_minutes = Column(Integer, nullable=True, comment="离线时长(分钟)")
is_resolved = Column(Boolean, default=False, comment="是否已恢复")
notification_sent = Column(Boolean, default=False, comment="是否已发送通知")
acknowledged_at = Column(DateTime, nullable=True, comment="用户确认时间")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
def __repr__(self):
return f"<AlertEvent(id={self.id}, device={self.device_id}, type={self.alert_type})>"
+46
View File
@@ -0,0 +1,46 @@
import enum
from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Enum, Text
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
class DeviceTypeEnum(str, enum.Enum):
server = "server" # 服务器
olt = "olt" # OLT
switch = "switch" # 交换机
firewall = "firewall" # 防火墙
other = "other" # 其他
class Device(Base):
__tablename__ = "devices"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(128), nullable=False, comment="设备名称")
ip = Column(String(45), nullable=False, index=True, comment="IP 地址")
device_type = Column(Enum(DeviceTypeEnum), nullable=False, default=DeviceTypeEnum.other, comment="设备类型")
location = Column(String(256), default="", comment="物理位置/地址")
project_name = Column(String(256), default="", comment="所属项目")
tags = Column(String(512), default="", comment="标签,逗号分隔")
# Ping 设置
ping_interval = Column(Integer, default=30, comment="ping 间隔(秒)")
alert_threshold = Column(Integer, default=5, comment="连续失败次数判离线")
is_enabled = Column(Boolean, default=True, comment="是否启用监控")
# 运行状态
current_status = Column(String(16), default="unknown", comment="当前状态: online/offline/unknown")
consecutive_failures = Column(Integer, default=0, comment="当前连续失败次数")
last_ping_time = Column(DateTime, nullable=True, comment="最后一次 ping 时间")
last_online_time = Column(DateTime, nullable=True, comment="最后一次在线时间")
last_offline_time = Column(DateTime, nullable=True, comment="最后一次离线时间")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
def __repr__(self):
return f"<Device(id={self.id}, name={self.name}, ip={self.ip})>"
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import Column, Integer, Float, Boolean, DateTime, BigInteger
from .device import Base
class PingRecord(Base):
"""单次 ping 结果记录"""
__tablename__ = "ping_records"
id = Column(BigInteger, primary_key=True, autoincrement=True)
device_id = Column(Integer, nullable=False, index=True)
is_alive = Column(Boolean, nullable=False, comment="是否通")
response_time_ms = Column(Float, nullable=True, comment="响应时间毫秒,不通则为 NULL")
round_num = Column(Integer, nullable=False, comment="轮次编号(从 1 递增)")
created_at = Column(DateTime, default=datetime.now, index=True, comment="记录时间")
def __repr__(self):
return f"<PingRecord(device={self.device_id}, alive={self.is_alive}, rtt={self.response_time_ms})>"
+27
View File
@@ -0,0 +1,27 @@
import enum
from datetime import datetime
from sqlalchemy import Boolean, Column, Integer, String, DateTime, Enum
from .device import Base
class UserRoleEnum(str, enum.Enum):
admin = "admin" # 管理员:可管理设备
viewer = "viewer" # 查看者:仅查看数据
class User(Base):
"""本地用户表,关联 Casdoor"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
casdoor_uid = Column(String(128), unique=True, nullable=False, comment="Casdoor 中的用户 ID")
username = Column(String(128), nullable=False, comment="用户名")
display_name = Column(String(128), default="", comment="显示名称")
role = Column(Enum(UserRoleEnum), nullable=False, default=UserRoleEnum.viewer, comment="角色")
wecom_userid = Column(String(128), default="", comment="企业微信 UserID(用于个人通知)")
is_active = Column(Boolean, default=True, comment="是否启用")
last_login_at = Column(DateTime, nullable=True, comment="最后登录时间")
created_at = Column(DateTime, default=datetime.now, comment="创建时间")
def __repr__(self):
return f"<User(id={self.id}, name={self.username}, role={self.role})>"
View File
+33
View File
@@ -0,0 +1,33 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class AlertEventOut(BaseModel):
id: int
device_id: int
device_name: str = ""
device_ip: str = ""
device_type: str = ""
location: str = ""
project_name: str = ""
alert_type: str
message: str
start_at: datetime
end_at: Optional[datetime] = None
duration_minutes: Optional[int] = None
is_resolved: bool
notification_sent: bool
created_at: datetime
model_config = {"from_attributes": True}
class AlertEventQuery(BaseModel):
page: int = 1
page_size: int = 20
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
+49
View File
@@ -0,0 +1,49 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, IPvAnyAddress
class DeviceCreate(BaseModel):
name: str
ip: str
device_type: str = "other"
location: str = ""
project_name: str = ""
tags: str = ""
ping_interval: int = 30
alert_threshold: int = 5
is_enabled: bool = True
class DeviceUpdate(BaseModel):
name: Optional[str] = None
ip: Optional[str] = None
device_type: Optional[str] = None
location: Optional[str] = None
project_name: Optional[str] = None
tags: Optional[str] = None
ping_interval: Optional[int] = None
alert_threshold: Optional[int] = None
is_enabled: Optional[bool] = None
class DeviceOut(BaseModel):
id: int
name: str
ip: str
device_type: str
location: str
project_name: str
tags: str
ping_interval: int
alert_threshold: int
is_enabled: bool
current_status: str
consecutive_failures: int
last_ping_time: Optional[datetime] = None
last_online_time: Optional[datetime] = None
last_offline_time: Optional[datetime] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+40
View File
@@ -0,0 +1,40 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class DeviceStatusSummary(BaseModel):
total: int
online: int
offline: int
checking: int
unknown: int
online_rate: float
class DeviceStatsItem(BaseModel):
device_id: int
device_name: str
device_ip: str
device_type: str
location: str
project_name: str
total_pings: int
alive_pings: int
packet_loss_rate: float
avg_response_time: Optional[float] = None
offline_count: int
total_offline_duration: int # 分钟
max_offline_duration: int
class TimeSeriesPoint(BaseModel):
time: datetime
value: float
class DashboardStats(BaseModel):
summary: DeviceStatusSummary
recent_offline: list # list[AlertEventOut]
offline_trend: list[TimeSeriesPoint]
packet_loss_top: list[DeviceStatsItem]
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()
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN CERTIFICATE-----
MIIE2TCCAsGgAwIBAgIDAeJAMA0GCSqGSIb3DQEBCwUAMCYxDjAMBgNVBAoTBWFk
bWluMRQwEgYDVQQDDAtjZXJ0X3drMWVlZjAeFw0yNTEyMTAxMDAzMzVaFw00NTEy
MTAxMDAzMzVaMCYxDjAMBgNVBAoTBWFkbWluMRQwEgYDVQQDDAtjZXJ0X3drMWVl
ZjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK78ajnNnwnutdZ48l92
hmqa2c8lP1IcpyB7CYVTKurQxSz5iQorYOVhR1UzluSpU8yiPPeFyTRD0pH+DzqG
otc+Alvxnka5DfP1z/P0asogkALJXouRR+YtUY6i1oo5tKlDbIGJNO2aQcOOak9b
YoqSRd9y/pq/bPjte7oww29hGQc03LgbNXmIb9n5NGznWCte1c88NUrTz9Dlpn2H
r3ncOmHpqpzg5NtXughnHsF4YCF+pPIgWlC0C6MtKk0fuJysCY5wpuA620pGL4zO
6QFv920MiWsdVWxcNo0aNQkXKGjEcy3LraXux2k/sH+E0e3GRXGhWYnkrK7i8kg+
V+Nm1YFgyGbY7V3yZ0mXPxb+iMlIvz885ViGsRnKqlR0pN1v8NbmzXPu9EkAO3wi
T/L4fxnETW1hd/ph+AdQ0jEpeyAMRjcl3kMjauOBqfU/THl7L6aUMXB3d05+JK0Y
uTc1nrZ0Qjh/5EG5XyvRSuKNVxpCB1XcAlAaBTuE5art6jQkCJINvFoOjoHZB65K
HJVvfMtMuVr7dLSbYOPHH6YJB2fUijNKoXnclAbxldX3fEALsC/SW7zMFF+3FThg
Seq3OEpNdbkRlQp5sORHeTkWtMO9A60GHvTqDamIqppC4fk71zIRpmEPlfGsOno5
gNhH8+NKH5Cvmfj4s7/S98D/AgMBAAGjEDAOMAwGA1UdEwEB/wQCMAAwDQYJKoZI
hvcNAQELBQADggIBAJLW6zjDKZdkfq00r88lM1IxaxRWmUEPQjINOa0GZH/DyAtd
fgYf35AN+NDEiIiDnJQTR3N8jbX1JySDClEOzv3wLXRiKhWcX8z4HOoieNmMu9lZ
6pf1lEaMX8WNxB475nfOueEEyHegbsbfpmYM/aVAiSxaKb87if7uDnxJeGqK2Ba0
c3LJnQ+H87gcAb84G78KXn/XwjRtwaf+Gy06EzycEnckEeN0vni0pRdw8coSudcX
726Kb+kZ4961G/XxQx4fKX+E4lXkx1CdxPBAws/uOjfciLnuVdT4yp6URXJKRw+/
R+flPtfvHXNDXB8BHj9ul4UJ4oSHGppx0BR80CmWWNDVdaoO+o0WieFOXG2U2PB+
p2pleJzckTDuOh5wIe2GFj9WKZTIn4S7ZA6hS5j5ea4bOX5m6cxXVaS5FPHHfHcN
FyUOEE4f61tv9omsFyl23YD/gmQzotFY+5YPS8DTb1soSzydKQODCq7SobyK8QFo
3FnOw1cnKuWDZbqkZS6TYHFc3doNaH77bBxgSgIBPlJewd98eZZCv+m8/mCOXCbo
+7HOgmOHJ1cOEtweCtgrCc2VNvV0w6RSwcZ+WmUAZMajvyEqikVVQzR2LtOOhOyr
REkkNRAX3PX69GDQLM9QSgGLv0f6nA5uTX9SFj71Iik6YAuUsvIK6uPpn/2B
-----END CERTIFICATE-----
+11
View File
@@ -0,0 +1,11 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
sqlalchemy[asyncio]>=2.0.30
aiosqlite>=0.20.0
asyncpg>=0.29.0
pydantic>=2.7.0
pydantic-settings>=2.2.0
python-jose[cryptography]>=3.3.0
httpx>=0.27.0
python-multipart>=0.0.9
websockets>=12.0
+62
View File
@@ -0,0 +1,62 @@
services:
backend:
build: ./backend
container_name: pingwatch-backend
restart: unless-stopped
env_file:
- ./backend/.env
environment:
- DATABASE_URL=postgresql+asyncpg://pingwatch:pingwatch123@db:5432/pingwatch
- TZ=Asia/Shanghai
ports:
- "8001:8000"
depends_on:
db:
condition: service_healthy
volumes:
- pingwatch_data:/app/data
networks:
- pingwatch-net
cap_add:
- NET_RAW # 允许 ICMP ping
- NET_ADMIN # 允许原始套接字
frontend:
build: ./frontend
container_name: pingwatch-frontend
restart: unless-stopped
environment:
- TZ=Asia/Shanghai
ports:
- "8080:80"
depends_on:
- backend
networks:
- pingwatch-net
db:
image: postgres:16-alpine
container_name: pingwatch-db
restart: unless-stopped
environment:
- TZ=Asia/Shanghai
- POSTGRES_DB=pingwatch
- POSTGRES_USER=pingwatch
- POSTGRES_PASSWORD=pingwatch123
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pingwatch"]
interval: 5s
timeout: 5s
retries: 5
networks:
- pingwatch-net
volumes:
postgres_data:
pingwatch_data:
networks:
pingwatch-net:
driver: bridge
+15
View File
@@ -0,0 +1,15 @@
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# 运行阶段
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PingWatch - 网络设备监控</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Vue 路由历史模式支持
location / {
try_files $uri $uri/ /index.html;
}
# API 反向代理
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# WebSocket 反向代理
location /ws {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
+1865
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "pingwatch-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"axios": "^1.7.0",
"echarts": "^5.5.0",
"vue-echarts": "^7.0.0",
"element-plus": "^2.7.0",
"@element-plus/icons-vue": "^2.3.0",
"dayjs": "^1.11.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.4.0"
}
}
+14
View File
@@ -0,0 +1,14 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f7fa;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const api = axios.create({
baseURL: '/api',
timeout: 15000,
})
// 请求拦截器:注入 token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:统一错误处理
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response) {
const { status, data } = error.response
if (status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else if (status === 403) {
ElMessage.error('权限不足')
} else {
ElMessage.error(data?.detail || `请求失败 (${status})`)
}
} else {
ElMessage.error('网络错误')
}
return Promise.reject(error)
}
)
// ========== 认证 ==========
export const authApi = {
login(code) {
return api.post('/auth/login', { code })
},
getMe() {
return api.get('/auth/me')
},
}
// ========== 设备 ==========
export const deviceApi = {
list(params) {
return api.get('/devices', { params })
},
get(id) {
return api.get(`/devices/${id}`)
},
create(data) {
return api.post('/devices', data)
},
update(id, data) {
return api.put(`/devices/${id}`, data)
},
delete(id) {
return api.delete(`/devices/${id}`)
},
downloadTemplate() {
return api.get('/devices/template/download', { responseType: 'blob' })
},
importDevices(file) {
const formData = new FormData()
formData.append('file', file)
return api.post('/devices/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
}
// ========== 告警 ==========
export const alertApi = {
list(params) {
return api.get('/alerts', { params })
},
getLatest(limit = 10) {
return api.get('/alerts/latest', { params: { limit } })
},
}
// ========== 统计 ==========
export const statsApi = {
getSummary() {
return api.get('/stats/summary')
},
getDashboard() {
return api.get('/stats/dashboard')
},
getOfflineTrend(days = 7) {
return api.get('/stats/offline-trend', { params: { days } })
},
getOnlineRateTrend(days = 7) {
return api.get('/stats/online-rate-trend', { params: { days } })
},
getPacketLossTop(limit = 10, days = 7) {
return api.get('/stats/packet-loss-top', { params: { limit, days } })
},
}
export default api
+19
View File
@@ -0,0 +1,19 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
+63
View File
@@ -0,0 +1,63 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
},
{
path: '/',
component: () => import('@/views/Layout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘' },
},
{
path: 'devices',
name: 'Devices',
component: () => import('@/views/Devices.vue'),
meta: { title: '设备列表' },
},
{
path: 'alerts',
name: 'Alerts',
component: () => import('@/views/Alerts.vue'),
meta: { title: '告警记录' },
},
{
path: 'stats',
name: 'Stats',
component: () => import('@/views/Stats.vue'),
meta: { title: '统计分析' },
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/Settings.vue'),
meta: { title: '系统设置', adminOnly: true },
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫:检查登录
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.name !== 'Login' && !token) {
next({ name: 'Login' })
} else {
next()
}
})
export default router
+85
View File
@@ -0,0 +1,85 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi, statsApi } from '@/api'
export const useAppStore = defineStore('app', () => {
// 用户状态
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
const token = ref(localStorage.getItem('token') || '')
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
function setUser(userData, tokenStr) {
user.value = userData
token.value = tokenStr
localStorage.setItem('user', JSON.stringify(userData))
localStorage.setItem('token', tokenStr)
}
function logout() {
user.value = null
token.value = ''
localStorage.removeItem('user')
localStorage.removeItem('token')
}
// 仪表盘统计缓存
const dashboardData = ref(null)
const loading = ref(false)
async function fetchDashboard() {
loading.value = true
try {
dashboardData.value = await statsApi.getDashboard()
} finally {
loading.value = false
}
}
// WebSocket 连接
let ws = null
function connectWebSocket() {
if (ws) return
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${location.host}/ws`
ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log('[WS] 已连接')
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'device_status_change') {
// 触发 dashboard 刷新
fetchDashboard()
}
} catch (e) {
// ignore
}
}
ws.onclose = () => {
console.log('[WS] 已断开,3秒后重连')
ws = null
setTimeout(() => connectWebSocket(), 3000)
}
// 心跳
setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send('ping')
}
}, 30000)
}
return {
user, token, isLoggedIn, isAdmin,
setUser, logout,
dashboardData, loading, fetchDashboard,
connectWebSocket,
}
})
+130
View File
@@ -0,0 +1,130 @@
<template>
<div class="alerts-page">
<!-- 过滤栏 -->
<el-card shadow="hover">
<el-row :gutter="16">
<el-col :span="5">
<el-select v-model="filterType" placeholder="告警类型" clearable @change="fetchAlerts" style="width:100%">
<el-option label="全部" value="" />
<el-option label="离线" value="offline" />
<el-option label="恢复" value="recovered" />
<el-option label="系统" value="system" />
</el-select>
</el-col>
<el-col :span="5">
<el-select v-model="filterResolved" placeholder="状态" clearable @change="fetchAlerts" style="width:100%">
<el-option label="全部" value="" />
<el-option label="未恢复" :value="false" />
<el-option label="已恢复" :value="true" />
</el-select>
</el-col>
<el-col :span="5">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" @change="fetchAlerts" style="width:100%" />
</el-col>
<el-col :span="5" :offset="4" style="text-align: right">
<el-button @click="fetchAlerts"><el-icon><Refresh /></el-icon> 刷新</el-button>
</el-col>
</el-row>
</el-card>
<!-- 告警列表 -->
<el-card shadow="hover" style="margin-top: 16px">
<el-table :data="alerts" stripe v-loading="loading" max-height="calc(100vh - 280px)">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="device_name" label="设备" width="130" />
<el-table-column prop="device_ip" label="IP" width="130" />
<el-table-column prop="device_type" label="类型" width="80">
<template #default="{ row }">
{{ typeMap[row.device_type] || row.device_type }}
</template>
</el-table-column>
<el-table-column prop="location" label="位置" width="130" show-overflow-tooltip />
<el-table-column prop="alert_type" label="告警类型" width="80">
<template #default="{ row }">
<el-tag :type="row.alert_type === 'offline' ? 'danger' : row.alert_type === 'recovered' ? 'success' : 'info'" size="small">
{{ row.alert_type === 'offline' ? '离线' : row.alert_type === 'recovered' ? '恢复' : '系统' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_minutes" label="离线时长(分)" width="100" align="center">
<template #default="{ row }">
{{ row.duration_minutes ?? '-' }}
</template>
</el-table-column>
<el-table-column prop="is_resolved" label="是否恢复" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.is_resolved ? 'success' : 'danger'" size="small">
{{ row.is_resolved ? '已恢复' : '未恢复' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="时间" width="160">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@change="fetchAlerts"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { alertApi } from '@/api'
import dayjs from 'dayjs'
const alerts = ref([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterType = ref('')
const filterResolved = ref('')
const dateRange = ref(null)
const typeMap = { server: '服务器', olt: 'OLT', switch: '交换机', firewall: '防火墙', other: '其他' }
async function fetchAlerts() {
loading.value = true
try {
const params = { page: page.value, page_size: pageSize.value }
if (filterType.value) params.alert_type = filterType.value
if (filterResolved.value !== '') params.is_resolved = filterResolved.value
if (dateRange.value) {
params.start_time = dayjs(dateRange.value[0]).startOf('day').toISOString()
params.end_time = dayjs(dateRange.value[1]).endOf('day').toISOString()
}
const data = await alertApi.list(params)
alerts.value = data.items
total.value = data.total
} finally {
loading.value = false
}
}
onMounted(fetchAlerts)
function formatTime(t) {
return t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
}
</script>
<style scoped>
.pagination-wrap {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
+183
View File
@@ -0,0 +1,183 @@
<template>
<div class="dashboard">
<!-- 状态卡片 -->
<el-row :gutter="20" class="stat-cards">
<el-col :span="6">
<el-card shadow="hover" class="stat-card total">
<div class="stat-value">{{ summary?.total || 0 }}</div>
<div class="stat-label">总设备数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card online">
<div class="stat-value">{{ summary?.online || 0 }}</div>
<div class="stat-label">在线</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card offline">
<div class="stat-value">{{ summary?.offline || 0 }}</div>
<div class="stat-label">离线</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card rate">
<div class="stat-value">{{ summary?.online_rate || 0 }}%</div>
<div class="stat-label">在线率</div>
</el-card>
</el-col>
</el-row>
<!-- 离线趋势 & 丢包率排名 -->
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="14">
<el-card shadow="hover">
<template #header>
<span>离线趋势近7天</span>
</template>
<v-chart :option="trendOption" style="height: 320px" autoresize />
</el-card>
</el-col>
<el-col :span="10">
<el-card shadow="hover">
<template #header>
<span>丢包率排名 TOP 10</span>
</template>
<el-table :data="topLossDevices" stripe size="small" max-height="320">
<el-table-column prop="device_name" label="设备" width="100" show-overflow-tooltip />
<el-table-column prop="packet_loss_rate" label="丢包率" width="80">
<template #default="{ row }">
<el-tag :type="row.packet_loss_rate > 10 ? 'danger' : row.packet_loss_rate > 3 ? 'warning' : 'success'" size="small">
{{ row.packet_loss_rate }}%
</el-tag>
</template>
</el-table-column>
<el-table-column prop="offline_count" label="离线次数" width="80" />
</el-table>
<div v-if="!topLossDevices.length" class="empty-hint">暂无数据</div>
</el-card>
</el-col>
</el-row>
<!-- 最近告警 -->
<el-card shadow="hover" style="margin-top: 20px">
<template #header>
<div class="card-header">
<span>最近告警</span>
<el-button text type="primary" @click="$router.push('/alerts')">查看全部</el-button>
</div>
</template>
<el-table :data="recentAlerts" stripe max-height="300">
<el-table-column prop="device_name" label="设备" width="140" />
<el-table-column prop="device_ip" label="IP" width="130" />
<el-table-column prop="alert_type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.alert_type === 'offline' ? 'danger' : 'success'" size="small">
{{ row.alert_type === 'offline' ? '离线' : '恢复' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_minutes" label="时长(分)" width="80" />
<el-table-column prop="location" label="位置" width="120" show-overflow-tooltip />
<el-table-column prop="created_at" label="时间" width="160">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useAppStore } from '@/stores/app'
import { statsApi } from '@/api'
import dayjs from 'dayjs'
const store = useAppStore()
const summary = ref(null)
const recentAlerts = ref([])
const topLossDevices = ref([])
const offlineTrend = ref([])
onMounted(() => {
fetchData()
})
watch(() => store.dashboardData, (data) => {
if (data) {
summary.value = data.summary
recentAlerts.value = data.recent_offline || []
topLossDevices.value = data.packet_loss_top || []
offlineTrend.value = data.offline_trend || []
}
})
async function fetchData() {
await store.fetchDashboard()
if (store.dashboardData) {
summary.value = store.dashboardData.summary
recentAlerts.value = store.dashboardData.recent_offline || []
topLossDevices.value = store.dashboardData.packet_loss_top || []
offlineTrend.value = store.dashboardData.offline_trend || []
}
}
const trendOption = computed(() => ({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: offlineTrend.value.map(i => dayjs(i.time).format('MM-DD')),
boundaryGap: false,
},
yAxis: { type: 'value', minInterval: 1 },
series: [{
name: '离线次数',
type: 'line',
smooth: true,
data: offlineTrend.value.map(i => i.value),
areaStyle: {
color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [{ offset: 0, color: 'rgba(64,158,255,0.3)' }, { offset: 1, color: 'rgba(64,158,255,0.05)' }] }
},
lineStyle: { color: '#409eff' },
itemStyle: { color: '#409eff' },
}],
}))
function formatTime(t) {
return t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
}
</script>
<style scoped>
.stat-cards .stat-card {
text-align: center;
}
.stat-cards .stat-value {
font-size: 32px;
font-weight: bold;
}
.stat-cards .stat-label {
font-size: 14px;
color: #909399;
margin-top: 8px;
}
.total .stat-value { color: #409eff; }
.online .stat-value { color: #67c23a; }
.offline .stat-value { color: #f56c6c; }
.rate .stat-value { color: #e6a23c; }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.empty-hint {
text-align: center;
color: #c0c4cc;
padding: 20px;
}
</style>
+320
View File
@@ -0,0 +1,320 @@
<template>
<div class="devices-page">
<!-- 工具栏 -->
<el-card shadow="hover">
<el-row :gutter="16">
<el-col :span="6">
<el-input v-model="search" placeholder="搜索设备名/IP/位置" clearable @input="fetchDevices" />
</el-col>
<el-col :span="4">
<el-select v-model="filterType" placeholder="设备类型" clearable @change="fetchDevices" style="width:100%">
<el-option label="全部" value="" />
<el-option label="服务器" value="server" />
<el-option label="OLT" value="olt" />
<el-option label="交换机" value="switch" />
<el-option label="防火墙" value="firewall" />
<el-option label="其他" value="other" />
</el-select>
</el-col>
<el-col :span="4">
<el-select v-model="filterStatus" placeholder="状态" clearable @change="fetchDevices" style="width:100%">
<el-option label="全部" value="" />
<el-option label="在线" value="online" />
<el-option label="离线" value="offline" />
<el-option label="检测中" value="checking" />
</el-select>
</el-col>
<el-col :span="6" :offset="4" style="text-align: right">
<el-button v-if="store.isAdmin" type="primary" @click="showAddDialog">
<el-icon><Plus /></el-icon> 添加设备
</el-button>
<el-button v-if="store.isAdmin" @click="downloadTemplate">
<el-icon><Download /></el-icon> 下载模板
</el-button>
<el-button v-if="store.isAdmin" @click="showImportDialog">
<el-icon><Upload /></el-icon> 批量导入
</el-button>
<el-button @click="fetchDevices">
<el-icon><Refresh /></el-icon> 刷新
</el-button>
</el-col>
</el-row>
</el-card>
<!-- 设备列表 -->
<el-card shadow="hover" style="margin-top: 16px">
<el-table :data="devices" stripe v-loading="loading" max-height="calc(100vh - 280px)">
<el-table-column prop="name" label="设备名称" width="140" fixed />
<el-table-column prop="ip" label="IP 地址" width="140" />
<el-table-column prop="device_type" label="类型" width="90">
<template #default="{ row }">
{{ typeMap[row.device_type] || row.device_type }}
</template>
</el-table-column>
<el-table-column prop="current_status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTagType(row.current_status)" size="small">
{{ statusMap[row.current_status] || row.current_status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="location" label="位置" width="140" show-overflow-tooltip />
<el-table-column prop="project_name" label="项目" width="140" show-overflow-tooltip />
<el-table-column prop="consecutive_failures" label="连续失败" width="90" align="center">
<template #default="{ row }">
<span :style="{ color: row.consecutive_failures > 0 ? '#f56c6c' : '#67c23a' }">
{{ row.consecutive_failures }}
</span>
</template>
</el-table-column>
<el-table-column prop="last_ping_time" label="最后 Ping" width="160">
<template #default="{ row }">
{{ row.last_ping_time ? dayjs(row.last_ping_time).format('MM-DD HH:mm:ss') : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right" v-if="store.isAdmin">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="showEditDialog(row)">编辑</el-button>
<el-button text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 批量导入对话框 -->
<el-dialog v-model="importDialogVisible" title="批量导入设备" width="560px" @closed="importResult = null">
<div style="margin-bottom: 16px; color: #606266; font-size: 14px">
1. <a href="#" @click.prevent="downloadTemplate">下载模板</a>按模板格式填写设备信息<br />
2. 选择填写好的 CSV 文件上传
</div>
<el-upload
ref="uploadRef"
drag
:auto-upload="false"
:limit="1"
accept=".csv"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<el-icon :size="40"><UploadFilled /></el-icon>
<div style="margin-top: 8px"> CSV 文件拖到此处或点击选择</div>
</el-upload>
<template #footer>
<el-button @click="importDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="importing" :disabled="!importFile" @click="handleImport">
开始导入
</el-button>
</template>
<!-- 导入结果 -->
<div v-if="importResult" style="margin-top: 16px">
<el-alert
:title="`成功导入 ${importResult.devices_added} 台设备`"
type="success"
:closable="false"
show-icon
/>
<div v-if="importResult.errors?.length" style="margin-top: 8px">
<p style="color: #f56c6c; font-size: 13px">以下行导入失败</p>
<ul style="color: #909399; font-size: 13px; padding-left: 20px">
<li v-for="(err, i) in importResult.errors" :key="i">{{ err }}</li>
</ul>
</div>
</div>
</el-dialog>
<!-- 添加/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="isEditing ? '编辑设备' : '添加设备'" width="520px">
<el-form :model="form" label-width="100px" :rules="rules" ref="formRef">
<el-form-item label="设备名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="IP 地址" prop="ip">
<el-input v-model="form.ip" />
</el-form-item>
<el-form-item label="设备类型" prop="device_type">
<el-select v-model="form.device_type" style="width:100%">
<el-option label="服务器" value="server" />
<el-option label="OLT" value="olt" />
<el-option label="交换机" value="switch" />
<el-option label="防火墙" value="firewall" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="位置" prop="location">
<el-input v-model="form.location" placeholder="如:县公安局机房" />
</el-form-item>
<el-form-item label="所属项目" prop="project_name">
<el-input v-model="form.project_name" placeholder="如:校园安防4+N项目" />
</el-form-item>
<el-form-item label="离线阈值" prop="alert_threshold">
<el-input-number v-model="form.alert_threshold" :min="1" :max="20" />
<span style="margin-left: 8px; color: #909399; font-size: 13px">连续失败次数</span>
</el-form-item>
<el-form-item label="是否启用">
<el-switch v-model="form.is_enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { deviceApi } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
import dayjs from 'dayjs'
const store = useAppStore()
const devices = ref([])
const loading = ref(false)
const search = ref('')
const filterType = ref('')
const filterStatus = ref('')
// 批量导入
const importDialogVisible = ref(false)
const importFile = ref(null)
const importing = ref(false)
const uploadRef = ref(null)
const importResult = ref(null)
function showImportDialog() {
importDialogVisible.value = true
}
function handleFileChange(file) {
importFile.value = file.raw
importResult.value = null
}
function handleFileRemove() {
importFile.value = null
importResult.value = null
}
async function downloadTemplate() {
try {
const blob = await deviceApi.downloadTemplate()
const url = window.URL.createObjectURL(new Blob([blob]))
const link = document.createElement('a')
link.href = url
link.download = 'device_template.csv'
link.click()
window.URL.revokeObjectURL(url)
} catch {
// error handled by interceptor
}
}
async function handleImport() {
if (!importFile.value) return
importing.value = true
try {
const result = await deviceApi.importDevices(importFile.value)
importResult.value = result
if (result.devices_added > 0) {
ElMessage.success(`成功导入 ${result.devices_added} 台设备`)
fetchDevices()
}
if (result.errors?.length) {
ElMessage.warning(`${result.errors.length} 条数据导入失败`)
}
uploadRef.value?.clearFiles()
importFile.value = null
} finally {
importing.value = false
}
}
const typeMap = { server: '服务器', olt: 'OLT', switch: '交换机', firewall: '防火墙', other: '其他' }
const statusMap = { online: '在线', offline: '离线', checking: '检测中', unknown: '未知' }
function statusTagType(status) {
return { online: 'success', offline: 'danger', checking: 'warning', unknown: 'info' }[status] || 'info'
}
async function fetchDevices() {
loading.value = true
try {
const params = {}
if (search.value) params.search = search.value
if (filterType.value) params.device_type = filterType.value
const list = await deviceApi.list(params)
if (filterStatus.value) {
devices.value = list.filter(d => d.current_status === filterStatus.value)
} else {
devices.value = list
}
} finally {
loading.value = false
}
}
onMounted(fetchDevices)
// 添加/编辑对话框
const dialogVisible = ref(false)
const isEditing = ref(false)
const editingId = ref(null)
const saving = ref(false)
const formRef = ref(null)
const form = ref({
name: '', ip: '', device_type: 'server', location: '', project_name: '',
alert_threshold: 5, is_enabled: true,
})
const rules = {
name: [{ required: true, message: '请输入设备名称' }],
ip: [{ required: true, message: '请输入 IP 地址' }],
device_type: [{ required: true, message: '请选择设备类型' }],
}
function showAddDialog() {
isEditing.value = false
editingId.value = null
form.value = { name: '', ip: '', device_type: 'server', location: '', project_name: '', alert_threshold: 5, is_enabled: true }
dialogVisible.value = true
}
function showEditDialog(device) {
isEditing.value = true
editingId.value = device.id
form.value = { ...device }
dialogVisible.value = true
}
async function handleSave() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (isEditing.value) {
await deviceApi.update(editingId.value, form.value)
ElMessage.success('更新成功')
} else {
await deviceApi.create(form.value)
ElMessage.success('添加成功')
}
dialogVisible.value = false
fetchDevices()
} finally {
saving.value = false
}
}
async function handleDelete(device) {
await ElMessageBox.confirm(`确定删除设备「${device.name}」吗?`, '警告', { type: 'warning' })
await deviceApi.delete(device.id)
ElMessage.success('删除成功')
fetchDevices()
}
</script>
+177
View File
@@ -0,0 +1,177 @@
<template>
<el-container class="layout-container">
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'" class="sidebar">
<div class="sidebar-header">
<span v-if="!isCollapse" class="sidebar-title">PingWatch</span>
<el-icon v-else :size="24"><Monitor /></el-icon>
</div>
<el-menu
:default-active="route.path"
:collapse="isCollapse"
:router="true"
background-color="#1d1e1f"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<el-menu-item index="/dashboard">
<el-icon><DataBoard /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/devices">
<el-icon><Monitor /></el-icon>
<span>设备列表</span>
</el-menu-item>
<el-menu-item index="/alerts">
<el-icon><WarningFilled /></el-icon>
<span>告警记录</span>
</el-menu-item>
<el-menu-item index="/stats">
<el-icon><TrendCharts /></el-icon>
<span>统计分析</span>
</el-menu-item>
<el-menu-item v-if="store.isAdmin" index="/settings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<!-- 顶部栏 -->
<el-header class="header">
<div class="header-left">
<el-icon
:size="20"
class="collapse-btn"
@click="isCollapse = !isCollapse"
>
<Fold v-if="!isCollapse" />
<Expand v-else />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta.title">
{{ route.meta.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-info">
{{ store.user?.display_name || store.user?.username }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>个人信息
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主体内容 -->
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const isCollapse = ref(false)
onMounted(() => {
store.connectWebSocket()
})
function handleCommand(command) {
if (command === 'logout') {
ElMessageBox.confirm('确定要退出登录吗?', '提示').then(() => {
store.logout()
router.push('/login')
ElMessage.success('已退出')
}).catch(() => {})
}
}
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.sidebar {
background-color: #1d1e1f;
transition: width 0.3s;
overflow: hidden;
}
.sidebar-header {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
font-weight: bold;
border-bottom: 1px solid #333;
}
.sidebar-title {
letter-spacing: 2px;
}
.header {
background: white;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
cursor: pointer;
color: #606266;
}
.collapse-btn:hover {
color: #409eff;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
cursor: pointer;
color: #606266;
display: flex;
align-items: center;
gap: 4px;
}
.user-info:hover {
color: #409eff;
}
.main-content {
background: #f5f7fa;
padding: 20px;
overflow-y: auto;
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>PingWatch</h1>
<p>网络设备离线监控系统</p>
</div>
<el-button
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
class="login-btn"
>
<el-icon style="margin-right: 8px"><User /></el-icon>
Casdoor 统一登录
</el-button>
<div class="login-footer">
首次登录将自动创建账号
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { authApi } from '@/api'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const loading = ref(false)
// Casdoor 配置
const CASDOOR_ENDPOINT = 'https://casdoor.dhdx.fun'
const CLIENT_ID = 'e46b9e1eb893027bdf2a'
const ORGANIZATION = 'dahua'
const APPLICATION = 'PingWatch'
const REDIRECT_URI = `${window.location.origin}/login`
const SCOPE = 'openid profile email'
onMounted(() => {
// 检查是否从 Casdoor 回调回来(带 code 参数)
const code = route.query.code
if (code) {
handleCallback(code)
}
})
async function handleLogin() {
// 构造 Casdoor OAuth 授权 URL 并跳转
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
redirect_uri: REDIRECT_URI,
scope: SCOPE,
state: generateState(),
organization: ORGANIZATION,
application: APPLICATION,
})
const authorizeUrl = `${CASDOOR_ENDPOINT}/login/oauth/authorize?${params.toString()}`
window.location.href = authorizeUrl
}
async function handleCallback(code) {
loading.value = true
try {
const res = await authApi.login(code)
store.setUser(res.user, res.token)
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (e) {
// 错误已在 axios 拦截器中处理
loading.value = false
}
}
/** 生成随机 state,防止 CSRF */
function generateState() {
const array = new Uint8Array(16)
crypto.getRandomValues(array)
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('')
}
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
border-radius: 16px;
padding: 48px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
min-width: 380px;
}
.login-header h1 {
margin: 0;
font-size: 28px;
color: #303133;
}
.login-header p {
color: #909399;
margin: 8px 0 32px;
}
.login-btn {
width: 100%;
font-size: 16px;
}
.login-footer {
margin-top: 24px;
font-size: 13px;
color: #c0c4cc;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div class="settings-page">
<!-- 用户管理 -->
<el-card shadow="hover">
<template #header>
<span>用户管理</span>
</template>
<el-table :data="users" stripe v-loading="loading">
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="display_name" label="显示名称" width="140" />
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'" size="small">
{{ row.role === 'admin' ? '管理员' : '查看者' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_login_at" label="最后登录" width="160">
<template #default="{ row }">
{{ row.last_login_at ? dayjs(row.last_login_at).format('YYYY-MM-DD HH:mm') : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button
text
type="primary"
size="small"
@click="toggleRole(row)"
:disabled="row.id === currentUserId"
>
{{ row.role === 'admin' ? '设为查看者' : '设为管理员' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 系统配置 -->
<el-card shadow="hover" style="margin-top: 20px">
<template #header>
<div class="card-header">
<span>数据保留设置</span>
</div>
</template>
<el-form label-width="150px">
<el-form-item label="Ping 记录保留">
<el-input-number v-model="pingRetention" :min="7" :max="365" />
</el-form-item>
<el-form-item label="告警记录保留">
<el-input-number v-model="alertRetention" :min="30" :max="730" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig">保存设置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { ElMessage, ElMessageBox } from 'element-plus'
import dayjs from 'dayjs'
import api from '@/api'
const store = useAppStore()
const currentUserId = computed(() => store.user?.id)
const users = ref([])
const loading = ref(false)
const pingRetention = ref(90)
const alertRetention = ref(365)
onMounted(() => {
fetchUsers()
})
async function fetchUsers() {
loading.value = true
try {
users.value = await api.get('/users')
} finally {
loading.value = false
}
}
async function toggleRole(user) {
const newRole = user.role === 'admin' ? 'viewer' : 'admin'
await ElMessageBox.confirm(
`确定将「${user.display_name || user.username}${newRole === 'admin' ? '设为管理员' : '降为查看者'}`,
'提示'
)
await api.put(`/users/${user.id}/role`, { role: newRole })
ElMessage.success('已更新')
fetchUsers()
}
function saveConfig() {
ElMessage.success('设置已保存(需要在后端更新配置)')
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+153
View File
@@ -0,0 +1,153 @@
<template>
<div class="stats-page">
<!-- 在线率趋势 -->
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>在线率趋势</span>
<el-radio-group v-model="rateDays" size="small" @change="fetchRateTrend">
<el-radio-button :value="7">7</el-radio-button>
<el-radio-button :value="30">30</el-radio-button>
<el-radio-button :value="90">90</el-radio-button>
</el-radio-group>
</div>
</template>
<v-chart :option="rateTrendOption" style="height: 360px" autoresize />
</el-card>
<el-row :gutter="20" style="margin-top: 20px">
<!-- 离线趋势 -->
<el-col :span="12">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>离线次数趋势</span>
<el-radio-group v-model="offlineDays" size="small" @change="fetchOfflineTrend">
<el-radio-button :value="7">7</el-radio-button>
<el-radio-button :value="30">30</el-radio-button>
<el-radio-button :value="90">90</el-radio-button>
</el-radio-group>
</div>
</template>
<v-chart :option="offlineTrendOption" style="height: 300px" autoresize />
</el-card>
</el-col>
<!-- 丢包率排名 -->
<el-col :span="12">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>丢包率排名</span>
<el-radio-group v-model="lossDays" size="small" @change="fetchLossTop">
<el-radio-button :value="7">7</el-radio-button>
<el-radio-button :value="30">30</el-radio-button>
<el-radio-button :value="90">90</el-radio-button>
</el-radio-group>
</div>
</template>
<el-table :data="lossTop" stripe size="small" max-height="300">
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="device_name" label="设备" width="100" show-overflow-tooltip />
<el-table-column prop="packet_loss_rate" label="丢包率" width="80">
<template #default="{ row }">
<el-tag :type="row.packet_loss_rate > 10 ? 'danger' : 'warning'" size="small">
{{ row.packet_loss_rate }}%
</el-tag>
</template>
</el-table-column>
<el-table-column prop="offline_count" label="离线次数" width="80" />
<el-table-column prop="total_offline_duration" label="总离线(分)" width="90" />
<el-table-column prop="avg_response_time" label="平均响应(ms)" width="100">
<template #default="{ row }">
{{ row.avg_response_time ?? '-' }}
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { statsApi } from '@/api'
import dayjs from 'dayjs'
const rateDays = ref(7)
const offlineDays = ref(7)
const lossDays = ref(7)
const rateTrend = ref([])
const offlineTrend = ref([])
const lossTop = ref([])
onMounted(() => {
fetchRateTrend()
fetchOfflineTrend()
fetchLossTop()
})
async function fetchRateTrend() {
const data = await statsApi.getOnlineRateTrend(rateDays.value)
rateTrend.value = data
}
async function fetchOfflineTrend() {
const data = await statsApi.getOfflineTrend(offlineDays.value)
offlineTrend.value = data
}
async function fetchLossTop() {
const data = await statsApi.getPacketLossTop(10, lossDays.value)
lossTop.value = data
}
const rateTrendOption = computed(() => ({
tooltip: { trigger: 'axis', valueFormatter: (v) => `${v}%` },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: rateTrend.value.map(i => dayjs(i.time).format('MM-DD')),
boundaryGap: false,
},
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
series: [{
name: '在线率',
type: 'line',
smooth: true,
data: rateTrend.value.map(i => i.value),
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [{ offset: 0, color: 'rgba(103,194,58,0.3)' }, { offset: 1, color: 'rgba(103,194,58,0.05)' }] } },
lineStyle: { color: '#67c23a' },
itemStyle: { color: '#67c23a' },
}],
}))
const offlineTrendOption = computed(() => ({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: offlineTrend.value.map(i => dayjs(i.time).format('MM-DD')),
boundaryGap: false,
},
yAxis: { type: 'value', minInterval: 1 },
series: [{
name: '离线次数',
type: 'bar',
data: offlineTrend.value.map(i => i.value),
itemStyle: { color: '#f56c6c', borderRadius: [4, 4, 0, 0] },
}],
}))
// computed for lossTop chart (optional - table suffices)
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
root: __dirname,
plugins: [vue()],
resolve: {
preserveSymlinks: true,
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
})
+150
View File
@@ -0,0 +1,150 @@
# 接入 LogHive 日志系统指南
## 前置准备
向管理员提供你的**项目名称**(如 `user-service`),管理员会返回一个 **API Key**
拿到 API Key 后,在你的项目 `.env` 文件中添加:
```env
# LogHive 日志系统
LOGHIVE_ENDPOINT=http://10.10.10.14:8000
LOGHIVE_PROJECT=你的项目名称
LOGHIVE_API_KEY=管理员给你的API-Key
```
> **不要**把 API 地址和 Key 硬编码在代码里,全部从环境变量读取。
---
## 方式一:零代码改动(推荐)
适用于已使用 Python 标准 `logging` 模块的项目。只需在入口文件添加 3 行:
```python
import logging
import os
from loghive_client import LogHiveHandler
handler = LogHiveHandler(
project=os.environ["LOGHIVE_PROJECT"],
api_key=os.environ["LOGHIVE_API_KEY"],
endpoint=os.environ["LOGHIVE_ENDPOINT"],
level=logging.INFO, # 只发送 INFO 及以上级别
)
logging.getLogger().addHandler(handler)
# 现有代码无需任何修改,所有日志自动发送到 LogHive
logging.info("服务启动成功")
logging.error("数据库连接超时", exc_info=True)
```
### 仅发送特定 logger 的日志
```python
logger = logging.getLogger("myapp.api")
logger.addHandler(handler)
logger.setLevel(logging.WARNING)
```
---
## 方式二:使用 LogHive SDK
适合需要更精细控制的场景,或者不想影响全局 logging 配置。
### 安装
```bash
pip install /path/to/loghive-client
```
### 同步项目使用
```python
import os
from loghive_client import LogHiveLogger
logger = LogHiveLogger(
project=os.environ["LOGHIVE_PROJECT"],
api_key=os.environ["LOGHIVE_API_KEY"],
endpoint=os.environ["LOGHIVE_ENDPOINT"],
)
logger.info("用户登录成功", user_id=42, ip="1.2.3.4")
logger.warning("API 限流触发", rate="90%")
logger.error("支付回调验签失败", trace_id="req-abc-123", exc_info=True)
logger.debug("缓存命中 key=user:42")
logger.critical("磁盘空间不足,服务即将崩溃")
```
SDK 在后台线程异步批量发送,**不会阻塞主线程**。程序退出时会自动 flush 剩余日志。
### 异步项目使用(FastAPI / aiohttp
```python
import os
from loghive_client import AsyncLogHiveLogger
async def main():
async with AsyncLogHiveLogger(
project=os.environ["LOGHIVE_PROJECT"],
api_key=os.environ["LOGHIVE_API_KEY"],
endpoint=os.environ["LOGHIVE_ENDPOINT"],
) as logger:
await logger.info("请求处理完成", path="/api/users", status=200)
```
---
## 方式三:直接调用 REST API(非 Python 项目)
```bash
curl -X POST $LOGHIVE_ENDPOINT/api/logs/ingest \
-H "Authorization: Bearer $LOGHIVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"project": "'$LOGHIVE_PROJECT'",
"entries": [
{
"level": "error",
"message": "服务异常",
"logger": "myapp.module",
"trace_id": "abc-123",
"extra": {"key": "value"}
}
]
}'
```
---
## 日志字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `level` | string | 是 | `debug` / `info` / `warning` / `error` / `critical` |
| `message` | string | 是 | 日志内容,最长 65536 字符 |
| `logger` | string | 否 | Logger 名称,默认 `root` |
| `trace_id` | string | 否 | 链路追踪 ID,用于关联跨模块日志 |
| `exception` | string | 否 | 异常堆栈,SDK 通过 `exc_info=True` 自动捕获 |
| `extra` | object | 否 | 任意键值对,支持嵌套结构 |
---
## 在 LogHive 前端查看
访问 `http://10.10.10.14:3000`,按项目、级别、关键词、时间范围搜索和统计。
---
## 常见问题
**Q: 发送失败会影响我的主业务吗?**
A: 不会。SDK 在后台线程异步发送,网络失败会自动重试 3 次,最终丢弃并记录本地 warning。
**Q: 日志量很大怎么办?**
A: SDK 默认每 2 秒或积攒 50 条批量发送。可通过 `batch_size``flush_interval` 参数调节。
**Q: 多个进程/worker 同时发送有问题吗?**
A: 没问题。每个进程创建自己的 LogHiveLogger 实例即可。