commit 848f8041694be664d2963405c3671a8ef2b44638 Author: v6ole Date: Sat May 9 15:02:04 2026 +0800 PingWatch 网络设备离线监控系统 - FastAPI 后端 + Vue 3 前端 - Docker Compose 一键部署 - Casdoor OAuth 认证集成 - LogHive 集中式日志 - 设备批量 CSV 导入/导出 - WebSocket 实时状态推送 - 企业微信告警通知 - fping 高性能并发 Ping 检测 Co-Authored-By: Claude Opus 4.7 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..925610a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +__pycache__ +*.pyc +.venv +venv +node_modules +dist +.git +.env +**/__pycache__ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fde9bd --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..ceff362 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c6318d4 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..4a93b17 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +from . import devices, alerts, stats, auth, ws, users diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py new file mode 100644 index 0000000..aea5432 --- /dev/null +++ b/backend/app/api/alerts.py @@ -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 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..5900968 --- /dev/null +++ b/backend/app/api/auth.py @@ -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, + } diff --git a/backend/app/api/devices.py b/backend/app/api/devices.py new file mode 100644 index 0000000..9809a5d --- /dev/null +++ b/backend/app/api/devices.py @@ -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 diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py new file mode 100644 index 0000000..5c11ef0 --- /dev/null +++ b/backend/app/api/stats.py @@ -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 diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..7dd733f --- /dev/null +++ b/backend/app/api/users.py @@ -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": "更新成功"} diff --git a/backend/app/api/ws.py b/backend/app/api/ws.py new file mode 100644 index 0000000..45e7629 --- /dev/null +++ b/backend/app/api/ws.py @@ -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(), + }) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..3cfbe38 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py new file mode 100644 index 0000000..941070a --- /dev/null +++ b/backend/app/core/auth.py @@ -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 diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..6e57789 --- /dev/null +++ b/backend/app/core/deps.py @@ -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) diff --git a/backend/app/core/loghive.py b/backend/app/core/loghive.py new file mode 100644 index 0000000..5fe09c9 --- /dev/null +++ b/backend/app/core/loghive.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..3c1cb14 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..cc4340f --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/alert_event.py b/backend/app/models/alert_event.py new file mode 100644 index 0000000..c78c4da --- /dev/null +++ b/backend/app/models/alert_event.py @@ -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"" diff --git a/backend/app/models/device.py b/backend/app/models/device.py new file mode 100644 index 0000000..19f4a8a --- /dev/null +++ b/backend/app/models/device.py @@ -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"" diff --git a/backend/app/models/ping_record.py b/backend/app/models/ping_record.py new file mode 100644 index 0000000..2757c9c --- /dev/null +++ b/backend/app/models/ping_record.py @@ -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"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..89cd4d4 --- /dev/null +++ b/backend/app/models/user.py @@ -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"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/alert.py b/backend/app/schemas/alert.py new file mode 100644 index 0000000..1ab35ec --- /dev/null +++ b/backend/app/schemas/alert.py @@ -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 diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py new file mode 100644 index 0000000..1bf88d1 --- /dev/null +++ b/backend/app/schemas/device.py @@ -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} diff --git a/backend/app/schemas/stats.py b/backend/app/schemas/stats.py new file mode 100644 index 0000000..e77fc0b --- /dev/null +++ b/backend/app/schemas/stats.py @@ -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] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/alerter.py b/backend/app/services/alerter.py new file mode 100644 index 0000000..8cbe7d1 --- /dev/null +++ b/backend/app/services/alerter.py @@ -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 diff --git a/backend/app/services/cleanup.py b/backend/app/services/cleanup.py new file mode 100644 index 0000000..f02bffc --- /dev/null +++ b/backend/app/services/cleanup.py @@ -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} 条") diff --git a/backend/app/services/pinger.py b/backend/app/services/pinger.py new file mode 100644 index 0000000..4f8d2f0 --- /dev/null +++ b/backend/app/services/pinger.py @@ -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} diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..999f01d --- /dev/null +++ b/backend/app/services/scheduler.py @@ -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() diff --git a/backend/dahua.pem b/backend/dahua.pem new file mode 100644 index 0000000..ab68b12 --- /dev/null +++ b/backend/dahua.pem @@ -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----- diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c0061d1 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fc72bde --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d78c8b3 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..be2704e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + PingWatch - 网络设备监控 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..c570f01 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d13dd93 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1865 @@ +{ + "name": "pingwatch-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pingwatch-frontend", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.7.0", + "dayjs": "^1.11.0", + "echarts": "^5.5.0", + "element-plus": "^2.7.0", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-echarts": "^7.0.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.8.tgz", + "integrity": "sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-7.0.3.tgz", + "integrity": "sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==", + "license": "MIT", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.5.1", + "vue": "^2.7.0 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/runtime-core": { + "optional": true + } + } + }, + "node_modules/vue-echarts/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f034d51 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..111ecee --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..1e762cf --- /dev/null +++ b/frontend/src/api/index.js @@ -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 diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..f1b4564 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..5d0a61c --- /dev/null +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/stores/app.js b/frontend/src/stores/app.js new file mode 100644 index 0000000..3c25fb6 --- /dev/null +++ b/frontend/src/stores/app.js @@ -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, + } +}) diff --git a/frontend/src/views/Alerts.vue b/frontend/src/views/Alerts.vue new file mode 100644 index 0000000..123e1f0 --- /dev/null +++ b/frontend/src/views/Alerts.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..dd9d44b --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/frontend/src/views/Devices.vue b/frontend/src/views/Devices.vue new file mode 100644 index 0000000..b1acdda --- /dev/null +++ b/frontend/src/views/Devices.vue @@ -0,0 +1,320 @@ + + + diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue new file mode 100644 index 0000000..5c16e3e --- /dev/null +++ b/frontend/src/views/Layout.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..0429e99 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..8a26029 --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/frontend/src/views/Stats.vue b/frontend/src/views/Stats.vue new file mode 100644 index 0000000..5becedc --- /dev/null +++ b/frontend/src/views/Stats.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..5086271 --- /dev/null +++ b/frontend/vite.config.js @@ -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, + }, + }, + }, +}) diff --git a/接入LogHive日志系统.md b/接入LogHive日志系统.md new file mode 100644 index 0000000..e4119c2 --- /dev/null +++ b/接入LogHive日志系统.md @@ -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 实例即可。