Files
PingWatch/backend/app/api/devices.py
T
v6ole 848f804169 PingWatch 网络设备离线监控系统
- FastAPI 后端 + Vue 3 前端
- Docker Compose 一键部署
- Casdoor OAuth 认证集成
- LogHive 集中式日志
- 设备批量 CSV 导入/导出
- WebSocket 实时状态推送
- 企业微信告警通知
- fping 高性能并发 Ping 检测

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:02:04 +08:00

210 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设备管理 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