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,9 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
**/__pycache__
|
||||||
+28
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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):
|
||||||
|
"""关闭 handler,flush 剩余日志"""
|
||||||
|
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()
|
||||||
@@ -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"}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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})>"
|
||||||
@@ -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})>"
|
||||||
@@ -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})>"
|
||||||
@@ -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})>"
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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} 条")
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -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-----
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;"]
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1865
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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 实例即可。
|
||||||
Reference in New Issue
Block a user