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:
@@ -0,0 +1 @@
|
||||
from . import devices, alerts, stats, auth, ws, users
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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": "更新成功"}
|
||||
@@ -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(),
|
||||
})
|
||||
Reference in New Issue
Block a user