b2c20ec43d
- 新增微信告警服务(wechat_service)和告警任务(alert_tasks) - 新增 WebSocket 实时推送端点 - 新增监控管理模块(monitor) - 增强统计仪表板:趋势图、区域分布、光功率历史 - 设备管理:添加坐标信息、标签系统、复合索引优化 - 前端:重构Dashboard/Charts页面,新增业务组件 - 新增5个数据库迁移(坐标、复合索引、标签、光功率历史、显示名) - 更新部署配置和脚本 - 新增测试框架基础结构
419 lines
14 KiB
Python
419 lines
14 KiB
Python
"""统计 API"""
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import func, case
|
|
from app.core.database import get_db
|
|
from app.middleware.permission_middleware import require_permission
|
|
from app.models.device import ONUDevice, DeviceStatusHistory, DeviceDailySnapshot
|
|
from datetime import datetime, timedelta, date
|
|
|
|
router = APIRouter(prefix="/api/stats", tags=["统计"])
|
|
|
|
|
|
@router.get("/dashboard")
|
|
def get_dashboard(
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('device.view')),
|
|
):
|
|
"""仪表板统计:总体、城区、城郊、乡镇在线率"""
|
|
# 每台设备最新状态子查询
|
|
latest_subq = (
|
|
db.query(
|
|
DeviceStatusHistory.onu_device_id,
|
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
|
)
|
|
.group_by(DeviceStatusHistory.onu_device_id)
|
|
.subquery()
|
|
)
|
|
latest_status_subq = (
|
|
db.query(
|
|
DeviceStatusHistory.onu_device_id,
|
|
DeviceStatusHistory.status
|
|
)
|
|
.join(
|
|
latest_subq,
|
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
|
)
|
|
.subquery()
|
|
)
|
|
|
|
rows = (
|
|
db.query(
|
|
ONUDevice.region,
|
|
ONUDevice.school_name,
|
|
func.count().label("total"),
|
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
|
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
|
)
|
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
|
.group_by(ONUDevice.region, ONUDevice.school_name)
|
|
.order_by(ONUDevice.region, ONUDevice.school_name)
|
|
.all()
|
|
)
|
|
|
|
overall = {"total": 0, "online": 0, "offline": 0}
|
|
urban_schools, suburban_schools = [], []
|
|
rural_towns = {}
|
|
|
|
for row in rows:
|
|
region = row.region or ""
|
|
total = int(row.total or 0)
|
|
online = int(row.online or 0)
|
|
offline = int(row.offline or 0)
|
|
overall["total"] += total
|
|
overall["online"] += online
|
|
overall["offline"] += offline
|
|
|
|
school_stat = {"name": row.school_name or "未知", "total": total, "online": online, "offline": offline}
|
|
|
|
if region == "城区":
|
|
urban_schools.append(school_stat)
|
|
elif region == "城郊":
|
|
suburban_schools.append(school_stat)
|
|
else:
|
|
if region not in rural_towns:
|
|
rural_towns[region] = {"region": region, "total": 0, "online": 0, "offline": 0, "schools": []}
|
|
rural_towns[region]["total"] += total
|
|
rural_towns[region]["online"] += online
|
|
rural_towns[region]["offline"] += offline
|
|
rural_towns[region]["schools"].append(school_stat)
|
|
|
|
def sort_by_rate(items):
|
|
return sorted(items, key=lambda x: x["online"] / x["total"] if x["total"] > 0 else 0)
|
|
|
|
def agg(items):
|
|
return {"total": sum(s["total"] for s in items), "online": sum(s["online"] for s in items), "offline": sum(s["offline"] for s in items)}
|
|
|
|
rural_list = sort_by_rate(list(rural_towns.values()))
|
|
|
|
return {
|
|
"overall": overall,
|
|
"urban": {**agg(urban_schools), "schools": sort_by_rate(urban_schools)},
|
|
"suburban": {**agg(suburban_schools), "schools": sort_by_rate(suburban_schools)},
|
|
"rural": {**agg(list(rural_towns.values())), "towns": rural_list},
|
|
}
|
|
|
|
|
|
@router.get("/summary")
|
|
def get_summary(
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('device.view')),
|
|
):
|
|
"""获取统计摘要"""
|
|
total = db.query(ONUDevice).count()
|
|
latest_status = db.query(
|
|
DeviceStatusHistory.status,
|
|
func.count(DeviceStatusHistory.id)
|
|
).group_by(DeviceStatusHistory.status).all()
|
|
status_dict = dict(latest_status)
|
|
return {"total": total, "online": status_dict.get('online', 0), "offline": status_dict.get('offline', 0)}
|
|
|
|
|
|
@router.get("/by-region")
|
|
def get_by_region(
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('device.view')),
|
|
):
|
|
"""各区域设备数量及在线率(用于饼图)"""
|
|
latest_subq = (
|
|
db.query(
|
|
DeviceStatusHistory.onu_device_id,
|
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
|
)
|
|
.group_by(DeviceStatusHistory.onu_device_id)
|
|
.subquery()
|
|
)
|
|
latest_status_subq = (
|
|
db.query(
|
|
DeviceStatusHistory.onu_device_id,
|
|
DeviceStatusHistory.status
|
|
)
|
|
.join(
|
|
latest_subq,
|
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
|
)
|
|
.subquery()
|
|
)
|
|
|
|
rows = (
|
|
db.query(
|
|
ONUDevice.region,
|
|
func.count().label("total"),
|
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
|
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
|
)
|
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
|
.group_by(ONUDevice.region)
|
|
.order_by(func.count().desc())
|
|
.all()
|
|
)
|
|
|
|
return [
|
|
{
|
|
"region": row.region or "未知",
|
|
"total": int(row.total or 0),
|
|
"online": int(row.online or 0),
|
|
"offline": int(row.offline or 0),
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
|
|
@router.get("/trend")
|
|
def get_trend(
|
|
days: int = 7,
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('device.view')),
|
|
):
|
|
"""获取状态趋势数据(优先查快照表,不足时实时聚合)"""
|
|
today = date.today()
|
|
date_range = [(today - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(days - 1, -1, -1)]
|
|
|
|
# 查快照表(不含今天,今天用实时数据)
|
|
snapshots = (
|
|
db.query(DeviceDailySnapshot)
|
|
.filter(DeviceDailySnapshot.snapshot_date.in_(date_range[:-1]))
|
|
.all()
|
|
)
|
|
snapshot_map = {s.snapshot_date: s for s in snapshots}
|
|
|
|
# 今天实时聚合 — 取每个设备最新状态(不限日期),反映真实当前状况
|
|
today_str = today.strftime('%Y-%m-%d')
|
|
|
|
latest_subq = (
|
|
db.query(
|
|
DeviceStatusHistory.onu_device_id,
|
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
|
|
)
|
|
.group_by(DeviceStatusHistory.onu_device_id)
|
|
.subquery()
|
|
)
|
|
today_row = (
|
|
db.query(
|
|
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
|
|
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
|
|
)
|
|
.join(
|
|
latest_subq,
|
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
|
)
|
|
.one()
|
|
)
|
|
|
|
result = []
|
|
for d in date_range:
|
|
if d == today_str:
|
|
result.append({
|
|
"date": d,
|
|
"online": int(today_row.online or 0),
|
|
"offline": int(today_row.offline or 0),
|
|
})
|
|
elif d in snapshot_map:
|
|
s = snapshot_map[d]
|
|
result.append({"date": d, "online": s.online, "offline": s.offline})
|
|
else:
|
|
# 快照缺失时实时聚合该天数据
|
|
day = datetime.strptime(d, '%Y-%m-%d').date()
|
|
day_start = datetime.combine(day, datetime.min.time())
|
|
day_end = datetime.combine(day, datetime.max.time())
|
|
subq = (
|
|
db.query(
|
|
DeviceStatusHistory.onu_device_id,
|
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
|
|
)
|
|
.filter(DeviceStatusHistory.checked_at.between(day_start, day_end))
|
|
.group_by(DeviceStatusHistory.onu_device_id)
|
|
.subquery()
|
|
)
|
|
row = (
|
|
db.query(
|
|
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
|
|
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
|
|
)
|
|
.join(
|
|
subq,
|
|
(DeviceStatusHistory.onu_device_id == subq.c.onu_device_id) &
|
|
(DeviceStatusHistory.checked_at == subq.c.max_checked_at)
|
|
)
|
|
.one()
|
|
)
|
|
result.append({"date": d, "online": int(row.online or 0), "offline": int(row.offline or 0)})
|
|
|
|
return result
|
|
|
|
@router.get("/olt-stats")
|
|
def get_olt_stats(
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('device.view')),
|
|
):
|
|
"""获取每台 OLT 下的设备在线率统计"""
|
|
from app.models.device import OLTDevice
|
|
|
|
latest_subq = (
|
|
db.query(
|
|
DeviceStatusHistory.onu_device_id,
|
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
|
)
|
|
.group_by(DeviceStatusHistory.onu_device_id)
|
|
.subquery()
|
|
)
|
|
latest_status_subq = (
|
|
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
|
.join(latest_subq,
|
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at))
|
|
.subquery()
|
|
)
|
|
|
|
rows = (
|
|
db.query(
|
|
OLTDevice.id,
|
|
OLTDevice.ip_address,
|
|
OLTDevice.location,
|
|
OLTDevice.region,
|
|
func.count(ONUDevice.id).label("total"),
|
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
|
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
|
)
|
|
.outerjoin(ONUDevice, ONUDevice.olt_id == OLTDevice.id)
|
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
|
.group_by(OLTDevice.id)
|
|
.order_by(OLTDevice.region, OLTDevice.location)
|
|
.all()
|
|
)
|
|
|
|
return [
|
|
{
|
|
"olt_id": r.id,
|
|
"name": r.location or r.ip_address,
|
|
"ip": r.ip_address,
|
|
"region": r.region or "未知",
|
|
"total": int(r.total or 0),
|
|
"online": int(r.online or 0),
|
|
"offline": int(r.offline or 0),
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.get("/offline-schools")
|
|
def get_offline_schools(
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('device.view')),
|
|
):
|
|
"""获取全部离线的学校列表"""
|
|
_subq = (
|
|
db.query(DeviceStatusHistory.onu_device_id,
|
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
|
|
.group_by(DeviceStatusHistory.onu_device_id).subquery()
|
|
)
|
|
_status_subq = (
|
|
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
|
.join(_subq,
|
|
(DeviceStatusHistory.onu_device_id == _subq.c.onu_device_id) &
|
|
(DeviceStatusHistory.checked_at == _subq.c.max_checked_at)).subquery()
|
|
)
|
|
rows = (
|
|
db.query(
|
|
ONUDevice.school_name, ONUDevice.region,
|
|
func.count().label("total"),
|
|
func.sum(case((_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
|
)
|
|
.outerjoin(_status_subq, ONUDevice.id == _status_subq.c.onu_device_id)
|
|
.group_by(ONUDevice.school_name, ONUDevice.region)
|
|
.having(func.sum(case((_status_subq.c.status == 'online', 1), else_=0)) == 0)
|
|
.all()
|
|
)
|
|
return [
|
|
{"school_name": r.school_name or "未知", "region": r.region or "未知", "total": int(r.total or 0)}
|
|
for r in rows if int(r.total or 0) > 0
|
|
]
|
|
|
|
|
|
@router.get("/model-distribution")
|
|
def get_model_distribution(
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('device.view')),
|
|
):
|
|
"""统计 ONU 设备型号分布"""
|
|
rows = (
|
|
db.query(
|
|
ONUDevice.model,
|
|
func.count(ONUDevice.id).label("count"),
|
|
)
|
|
.filter(ONUDevice.model.isnot(None), ONUDevice.model != '')
|
|
.group_by(ONUDevice.model)
|
|
.order_by(func.count(ONUDevice.id).desc())
|
|
.all()
|
|
)
|
|
unknown = db.query(func.count(ONUDevice.id)).filter(
|
|
(ONUDevice.model.is_(None)) | (ONUDevice.model == '')
|
|
).scalar() or 0
|
|
|
|
result = [{"model": r.model or "未知", "count": r.count} for r in rows]
|
|
if unknown > 0:
|
|
result.append({"model": "未知型号", "count": unknown})
|
|
return result
|
|
|
|
|
|
@router.get("/school-locations")
|
|
def get_school_locations(
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(require_permission('device.view')),
|
|
):
|
|
"""获取各学校的聚合位置数据(用于地图展示)"""
|
|
latest_subq = (
|
|
db.query(
|
|
DeviceStatusHistory.onu_device_id,
|
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
|
)
|
|
.group_by(DeviceStatusHistory.onu_device_id)
|
|
.subquery()
|
|
)
|
|
latest_status_subq = (
|
|
db.query(
|
|
DeviceStatusHistory.onu_device_id,
|
|
DeviceStatusHistory.status
|
|
)
|
|
.join(
|
|
latest_subq,
|
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
|
)
|
|
.subquery()
|
|
)
|
|
|
|
rows = (
|
|
db.query(
|
|
ONUDevice.school_name,
|
|
ONUDevice.region,
|
|
ONUDevice.latitude,
|
|
ONUDevice.longitude,
|
|
func.count().label("total"),
|
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
|
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
|
)
|
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
|
.filter(ONUDevice.latitude.isnot(None))
|
|
.filter(ONUDevice.longitude.isnot(None))
|
|
.group_by(ONUDevice.school_name, ONUDevice.region, ONUDevice.latitude, ONUDevice.longitude)
|
|
.all()
|
|
)
|
|
|
|
return [
|
|
{
|
|
"school_name": row.school_name or "未知",
|
|
"region": row.region or "未知",
|
|
"latitude": row.latitude,
|
|
"longitude": row.longitude,
|
|
"total": int(row.total or 0),
|
|
"online": int(row.online or 0),
|
|
"offline": int(row.offline or 0),
|
|
}
|
|
for row in rows
|
|
]
|
|
|