feat(alerts): 添加微信告警、WebSocket实时推送和地图功能

- 新增微信告警服务(wechat_service)和告警任务(alert_tasks)
- 新增 WebSocket 实时推送端点
- 新增监控管理模块(monitor)
- 增强统计仪表板:趋势图、区域分布、光功率历史
- 设备管理:添加坐标信息、标签系统、复合索引优化
- 前端:重构Dashboard/Charts页面,新增业务组件
- 新增5个数据库迁移(坐标、复合索引、标签、光功率历史、显示名)
- 更新部署配置和脚本
- 新增测试框架基础结构
This commit is contained in:
2026-06-11 15:16:29 +08:00
parent 3453441754
commit b2c20ec43d
45 changed files with 2089 additions and 107 deletions
+92
View File
@@ -1,5 +1,8 @@
"""设备管理 API"""
import csv
import io
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import asc, desc, distinct, or_
from pydantic import BaseModel
@@ -20,6 +23,7 @@ def get_devices(
school_name: str = None,
keyword: str = None,
status: str = None,
tag: str = None,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('device.view')),
):
@@ -68,6 +72,8 @@ def get_devices(
query = query.filter(ONUDevice.region == region)
if school_name:
query = query.filter(ONUDevice.school_name.contains(school_name))
if tag:
query = query.filter(ONUDevice.tags.contains(tag))
if keyword:
query = query.filter(
or_(
@@ -395,6 +401,7 @@ class DeviceUpdate(BaseModel):
room_number: Optional[str] = None
place_type: Optional[str] = None
notes: Optional[str] = None
tags: Optional[str] = None
class DeviceReplaceRequest(BaseModel):
@@ -430,6 +437,7 @@ def update_device(
device.room_number = body.room_number or None
device.place_type = body.place_type or None
device.notes = body.notes or None
device.tags = body.tags or None
# 若该设备 MAC 在 new_devices 待入库列表中,自动移除(已在设备列表中补全信息)
from app.models.device import NewDevice
@@ -613,6 +621,18 @@ def get_device_optical_power(
data = IMCService().get_optical_power(device.mac_address)
if data is None:
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
# 记录光功率历史
try:
from app.models.device import OpticalPowerHistory
db.add(OpticalPowerHistory(
onu_device_id=device_id,
power_in=data.get("powerIn"),
power_out=data.get("powerOut"),
))
db.commit()
except Exception:
pass
return OpticalPowerResponse(
power_in=data.get("powerIn"),
power_out=data.get("powerOut"),
@@ -661,3 +681,75 @@ def get_onu_events(
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
finally:
ssh.close()
@router.get("/{device_id}/optical-power-history")
def get_optical_power_history(
device_id: int,
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取设备光功率历史记录"""
from app.models.device import OpticalPowerHistory
rows = (
db.query(OpticalPowerHistory)
.filter(OpticalPowerHistory.onu_device_id == device_id)
.order_by(OpticalPowerHistory.recorded_at.desc())
.limit(limit)
.all()
)
return [
{"power_in": r.power_in, "power_out": r.power_out,
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None}
for r in rows
]
@router.get("/tags")
def get_all_tags(db: Session = Depends(get_db)):
"""获取所有不重复的设备标签"""
from sqlalchemy import func as _func
rows = db.query(ONUDevice.tags).filter(
ONUDevice.tags.isnot(None), ONUDevice.tags != ''
).all()
tags = set()
for (tag_str,) in rows:
for t in tag_str.split(','):
t = t.strip()
if t:
tags.add(t)
return sorted(tags)
@router.get("/export/csv")
def export_devices_csv(
region: Optional[str] = Query(None),
school_name: Optional[str] = Query(None),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""导出设备列表为 CSV"""
from sqlalchemy import func as _func
query = db.query(ONUDevice)
if region:
query = query.filter(ONUDevice.region == region)
if school_name:
query = query.filter(ONUDevice.school_name == school_name)
devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"])
for d in devices:
writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "",
d.place_type or "", d.room_number or "", d.port_id or "", d.model or "",
d.loid or "", d.distance_m or "", d.notes or ""])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=onu_devices.csv"}
)