fix(ipam): 修复IP管理页面网段筛选不生效及下拉框过窄
- Ips.vue: el-select 添加 width: 280px,修复网段下拉框过窄 - Ips.vue: query 参数 networkId → network_id(与 FastAPI snake_case 一致) - Scan.vue: el-select 用 network.id 代替整个 network 对象作为 value,修复切换网段不生效 - enhanced_scan_service.py: 替换失效的 scapy ARP 扫描为 arp-scan 命令;扫描结果自动创建缺失 IP 记录
This commit is contained in:
+27
@@ -0,0 +1,27 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
venv/
|
||||
.env
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
/tmp/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,277 @@
|
||||
# IPAM 管理系统 - 完整功能
|
||||
|
||||
企业级 IP 地址管理系统,支持自动化扫描、发现和管理网络 IP 资产。
|
||||
|
||||
## ✅ 已完成功能
|
||||
|
||||
### 🎯 P0 - 核心功能
|
||||
- ✅ **FastAPI 后端框架** - 高性能 RESTful API
|
||||
- ✅ **MySQL 数据库** - 持久化存储
|
||||
- ✅ **Redis 缓存** - 任务队列和缓存
|
||||
- ✅ **网段管理** - CIDR 网段增删改查,自动创建 IP 记录
|
||||
- ✅ **IP 资产台账** - IP 地址全生命周期管理
|
||||
- ✅ **Ping 扫描引擎** - ICMP 在线状态检测
|
||||
|
||||
### 🚀 P1 - 高级功能
|
||||
- ✅ **ARP 扫描** - 获取 MAC 地址
|
||||
- ✅ **反向 DNS 解析** - 获取主机名
|
||||
- ✅ **MAC 厂商识别** - 内置 OUI 数据库
|
||||
- ✅ **Celery 异步任务** - 后台并发扫描
|
||||
- ✅ **SNMP 设备集成**
|
||||
- SNMP v2c/v3 支持
|
||||
- 设备连接测试
|
||||
- ARP 表自动采集
|
||||
- 接口信息采集
|
||||
- ✅ **告警与异常检测**
|
||||
- IP 冲突告警
|
||||
- 未授权接入检测
|
||||
- 网段耗尽预警
|
||||
- 设备离线告警
|
||||
- 新设备发现告警
|
||||
- MAC 地址白名单
|
||||
|
||||
### 🎨 P2 - 前端界面
|
||||
- ✅ **Vue 3 + Element Plus** - 现代化 UI 框架
|
||||
- ✅ **仪表盘** - 统计概览、网段使用率、告警统计
|
||||
- ✅ **网段管理** - 网段 CRUD、扫描、导出
|
||||
- ✅ **IP 地址管理** - 筛选、搜索、编辑
|
||||
- ✅ **扫描管理** - 综合扫描、结果导出
|
||||
- ✅ **SNMP 管理** - 设备、凭据、ARP 表
|
||||
- ✅ **告警中心** - 告警列表、统计、白名单
|
||||
|
||||
## 🚀 快速启动
|
||||
|
||||
### 方式一:使用启动脚本(推荐)
|
||||
|
||||
```bash
|
||||
cd /root/ipam
|
||||
|
||||
# 启动所有服务
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### 方式二:手动启动
|
||||
|
||||
**1. 启动数据库和缓存**
|
||||
```bash
|
||||
# MySQL 已运行在 3308 端口
|
||||
# Redis 已运行在 6379 端口
|
||||
```
|
||||
|
||||
**2. 启动后端服务**
|
||||
```bash
|
||||
cd /root/ipam/backend
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8008
|
||||
```
|
||||
|
||||
**3. 启动 Celery Worker**
|
||||
```bash
|
||||
cd /root/ipam/backend
|
||||
source venv/bin/activate
|
||||
celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
|
||||
```
|
||||
|
||||
**4. 启动前端服务**
|
||||
```bash
|
||||
cd /root/ipam/frontend
|
||||
npm run dev -- --host 0.0.0.0 --port 3000
|
||||
```
|
||||
|
||||
## 🌐 访问地址
|
||||
|
||||
| 服务 | 地址 | 说明 |
|
||||
|------|------|------|
|
||||
| 前端界面 | http://服务器IP:3000 | Web 管理界面 |
|
||||
| 后端 API | http://服务器IP:8008 | API 服务 |
|
||||
| API 文档 | http://服务器IP:8008/docs | Swagger 在线文档 |
|
||||
| MySQL | 服务器IP:3308 | 数据库 |
|
||||
| Redis | 服务器IP:6379 | 缓存和任务队列 |
|
||||
|
||||
## 📖 API 使用示例
|
||||
|
||||
### 1. 创建网段
|
||||
```bash
|
||||
curl -X POST http://localhost:8008/api/v1/networks \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"cidr": "192.168.1.0/24",
|
||||
"name": "办公网络",
|
||||
"group_name": "office",
|
||||
"gateway": "192.168.1.1"
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. 触发扫描
|
||||
```bash
|
||||
curl -X POST http://localhost:8008/api/v1/scan/comprehensive/1
|
||||
```
|
||||
|
||||
### 3. 查看统计
|
||||
```bash
|
||||
curl http://localhost:8008/api/v1/networks/stats
|
||||
```
|
||||
|
||||
## 🏗️ 项目结构
|
||||
|
||||
```
|
||||
/root/ipam/
|
||||
├── backend/ # 后端代码
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ │ └── v1/
|
||||
│ │ │ ├── networks.py # 网段管理
|
||||
│ │ │ ├── ips.py # IP管理
|
||||
│ │ │ ├── scan.py # 扫描管理
|
||||
│ │ │ ├── enhanced_scan.py # 增强扫描
|
||||
│ │ │ ├── snmp.py # SNMP管理
|
||||
│ │ │ └── alerts.py # 告警管理
|
||||
│ │ ├── core/ # 核心配置
|
||||
│ │ │ ├── config.py # 配置文件
|
||||
│ │ │ └── database.py # 数据库连接
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ │ ├── network.py # 网段/IP/扫描任务
|
||||
│ │ │ ├── snmp.py # SNMP设备/凭据
|
||||
│ │ │ └── alert.py # 告警模型
|
||||
│ │ ├── schemas/ # Pydantic 验证模型
|
||||
│ │ │ └── network.py
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ │ ├── network_service.py # 网段服务
|
||||
│ │ │ ├── scan_service.py # Ping扫描服务
|
||||
│ │ │ ├── enhanced_scan_service.py # ARP/DNS扫描服务
|
||||
│ │ │ ├── snmp_service.py # SNMP服务
|
||||
│ │ │ └── alert_service.py # 告警服务
|
||||
│ │ ├── tasks/ # Celery 任务
|
||||
│ │ │ ├── celery_app.py # Celery 应用
|
||||
│ │ │ └── scan_tasks.py # 扫描任务
|
||||
│ │ └── main.py # FastAPI 应用入口
|
||||
│ ├── requirements.txt # Python 依赖
|
||||
│ └── venv/ # Python 虚拟环境
|
||||
├── frontend/ # 前端代码
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ │ ├── Dashboard.vue # 仪表盘
|
||||
│ │ │ ├── Networks.vue # 网段管理
|
||||
│ │ │ ├── IPs.vue # IP地址
|
||||
│ │ │ ├── Scan.vue # 扫描管理
|
||||
│ │ │ ├── SNMP.vue # SNMP管理
|
||||
│ │ │ └── Alerts.vue # 告警中心
|
||||
│ │ ├── layout/ # 布局组件
|
||||
│ │ │ └── MainLayout.vue # 主布局
|
||||
│ │ ├── router/ # 路由配置
|
||||
│ │ │ └── index.js
|
||||
│ │ ├── api/ # API 客户端
|
||||
│ │ │ └── index.js
|
||||
│ │ ├── utils/ # 工具函数
|
||||
│ │ │ └── api.js
|
||||
│ │ ├── App.vue # 根组件
|
||||
│ │ └── main.js # 应用入口
|
||||
│ ├── package.json
|
||||
│ └── vite.config.js # Vite 配置
|
||||
├── docs/ # 文档
|
||||
├── docker-compose.yml # Docker 配置
|
||||
├── start.sh # 启动脚本
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🔧 技术栈
|
||||
|
||||
### 后端
|
||||
- **Web 框架**: FastAPI 0.110
|
||||
- **ORM**: SQLAlchemy 2.0
|
||||
- **数据库**: MySQL 8.0
|
||||
- **缓存/队列**: Redis 7.0
|
||||
- **异步任务**: Celery 5.3
|
||||
- **SNMP**: PySNMP 4.4.12
|
||||
- **网络探测**: Scapy 2.5.0
|
||||
- **HTTP 客户端**: httpx
|
||||
|
||||
### 前端
|
||||
- **框架**: Vue 3.5
|
||||
- **UI 组件**: Element Plus 2.14
|
||||
- **路由**: Vue Router 4.6
|
||||
- **状态管理**: Pinia 4.0
|
||||
- **HTTP 客户端**: Axios 1.18
|
||||
- **构建工具**: Vite 5.0
|
||||
|
||||
## 📊 数据库表
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| networks | 网段信息 |
|
||||
| ip_addresses | IP 地址台账 |
|
||||
| scan_tasks | 扫描任务记录 |
|
||||
| snmp_credentials | SNMP 凭据配置 |
|
||||
| network_devices | 网络设备列表 |
|
||||
| arp_entries | ARP 表记录 |
|
||||
| mac_address_table | MAC 地址表 |
|
||||
| device_interfaces | 设备接口信息 |
|
||||
| alerts | 告警记录 |
|
||||
| alert_rules | 告警规则配置 |
|
||||
| whitelisted_macs | MAC 地址白名单 |
|
||||
|
||||
## 🎯 核心特性
|
||||
|
||||
### 1. 智能扫描
|
||||
- **ICMP Ping**: 快速检测在线状态
|
||||
- **ARP 扫描**: 二层网络 MAC 发现
|
||||
- **反向 DNS**: 自动解析主机名
|
||||
- **并发扫描**: Celery 异步执行,不阻塞 Web 服务
|
||||
|
||||
### 2. SNMP 设备管理
|
||||
- 支持 SNMP v2c/v3
|
||||
- 自动采集 ARP 表、接口信息
|
||||
- 设备连通性测试
|
||||
- 轮询状态监控
|
||||
|
||||
### 3. 智能告警
|
||||
- IP 冲突自动检测
|
||||
- 未授权设备接入告警
|
||||
- 网段使用率预警
|
||||
- MAC 白名单机制
|
||||
|
||||
### 4. 厂商识别
|
||||
- 内置 IEEE OUI 数据库
|
||||
- 自动识别 3000+ 设备厂商
|
||||
- 支持 Cisco、H3C、华为、华三等主流厂商
|
||||
|
||||
## 📝 开发计划
|
||||
|
||||
### 待实现功能 (P3)
|
||||
- [ ] 历史趋势图表
|
||||
- [ ] 报告导出
|
||||
- [ ] 用户权限管理
|
||||
- [ ] 邮件/钉钉/企业微信通知
|
||||
- [ ] VLAN 自动发现
|
||||
- [ ] 拓扑图自动生成
|
||||
- [ ] 端口控制(关闭/开启)
|
||||
- [ ] 资产变更审计日志
|
||||
|
||||
## 🔐 安全建议
|
||||
|
||||
1. **修改默认密码**
|
||||
- MySQL root 密码
|
||||
- Redis 密码配置
|
||||
|
||||
2. **配置防火墙**
|
||||
- 仅开放必要端口:3000(前端)、8008(API)
|
||||
- 限制 3308(MySQL)、6379(Redis) 访问来源
|
||||
|
||||
3. **HTTPS**
|
||||
- 生产环境建议配置 Nginx 反向代理 + SSL
|
||||
|
||||
4. **访问控制**
|
||||
- 配置 API 认证 (JWT)
|
||||
- 限制管理后台访问来源
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如有问题或建议,请查看 API 文档:
|
||||
- Swagger UI: http://服务器IP:8008/docs
|
||||
- ReDoc: http://服务器IP:8008/redoc
|
||||
|
||||
---
|
||||
|
||||
**版本**: 1.0.0
|
||||
**最后更新**: 2026-07-17
|
||||
@@ -0,0 +1,19 @@
|
||||
# 数据库配置
|
||||
MYSQL_ROOT_PASSWORD=ipam2024
|
||||
MYSQL_DATABASE=ipam
|
||||
MYSQL_USER=ipam
|
||||
MYSQL_PASSWORD=ipam2024
|
||||
|
||||
# 应用配置
|
||||
DATABASE_URL=mysql+pymysql://ipam:***@localhost:3306/ipam
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
CELERY_BROKER_URL=redis://localhost:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379/2
|
||||
|
||||
# JWT配置
|
||||
SECRET_KEY=your-super-secret-key-here-change-in-production
|
||||
|
||||
# 扫描配置
|
||||
PING_TIMEOUT=2
|
||||
PING_RETRIES=2
|
||||
SCAN_CONCURRENCY=50
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
iputils-ping \
|
||||
net-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装Python依赖
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
|
||||
# 创建启动脚本
|
||||
RUN echo '#!/bin/bash\n\
|
||||
echo "Waiting for MySQL..."\n\
|
||||
sleep 10\n\
|
||||
echo "Starting FastAPI server..."\n\
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload' > /app/start.sh && chmod +x /app/start.sh
|
||||
|
||||
CMD ["/app/start.sh"]
|
||||
@@ -0,0 +1 @@
|
||||
# app/__init__.py
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import networks, ips, scan, enhanced_scan, async_scan, snmp, alerts, auth, audit
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
api_router.include_router(networks.router)
|
||||
api_router.include_router(ips.router)
|
||||
api_router.include_router(scan.router)
|
||||
api_router.include_router(enhanced_scan.router)
|
||||
api_router.include_router(async_scan.router)
|
||||
api_router.include_router(snmp.router)
|
||||
api_router.include_router(alerts.router)
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(audit.router)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,182 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.services.alert_service import AlertService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/alerts",
|
||||
tags=["告警管理"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", summary="获取告警列表")
|
||||
def get_alerts(
|
||||
status: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
alert_type: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取告警列表"""
|
||||
from app.models.alert import Alert
|
||||
|
||||
query = db.query(Alert)
|
||||
|
||||
if status:
|
||||
query = query.filter(Alert.status == status)
|
||||
if severity:
|
||||
query = query.filter(Alert.severity == severity)
|
||||
if alert_type:
|
||||
query = query.filter(Alert.alert_type == alert_type)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(Alert.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@router.get("/{alert_id}", summary="获取告警详情")
|
||||
def get_alert(alert_id: int, db: Session = Depends(get_db)):
|
||||
from app.models.alert import Alert
|
||||
|
||||
alert = db.query(Alert).filter(Alert.id == alert_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="告警不存在")
|
||||
return alert
|
||||
|
||||
|
||||
@router.post("/{alert_id}/acknowledge", summary="确认告警")
|
||||
def acknowledge_alert(alert_id: int, acknowledged_by: str = "system", db: Session = Depends(get_db)):
|
||||
"""确认告警"""
|
||||
alert = AlertService.acknowledge_alert(db, alert_id, acknowledged_by)
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="告警不存在")
|
||||
return {"message": "告警已确认", "alert": alert}
|
||||
|
||||
|
||||
@router.post("/{alert_id}/resolve", summary="解决告警")
|
||||
def resolve_alert(alert_id: int, resolved_by: str = "system", notes: str = "", db: Session = Depends(get_db)):
|
||||
"""标记告警为已解决"""
|
||||
alert = AlertService.resolve_alert(db, alert_id, resolved_by, notes)
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="告警不存在")
|
||||
return {"message": "告警已解决", "alert": alert}
|
||||
|
||||
|
||||
@router.post("/{alert_id}/ignore", summary="忽略告警")
|
||||
def ignore_alert(alert_id: int, db: Session = Depends(get_db)):
|
||||
"""忽略告警"""
|
||||
alert = AlertService.ignore_alert(db, alert_id)
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="告警不存在")
|
||||
return {"message": "告警已忽略", "alert": alert}
|
||||
|
||||
|
||||
@router.post("/detect/run", summary="运行所有检测")
|
||||
def run_detection(db: Session = Depends(get_db)):
|
||||
"""立即运行所有告警检测"""
|
||||
results = AlertService.run_all_detections(db)
|
||||
return {"message": "检测完成", "results": results}
|
||||
|
||||
|
||||
@router.get("/statistics/summary", summary="告警统计摘要")
|
||||
def get_alert_statistics(db: Session = Depends(get_db)):
|
||||
"""获取告警统计信息"""
|
||||
stats = AlertService.get_statistics(db)
|
||||
return stats
|
||||
|
||||
|
||||
# ========== MAC 白名单管理 ==========
|
||||
|
||||
@router.get("/whitelist/macs", summary="获取 MAC 白名单")
|
||||
def get_mac_whitelist(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取 MAC 地址白名单"""
|
||||
total, items = AlertService.get_mac_whitelist(db, skip=skip, limit=limit)
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@router.post("/whitelist/macs", summary="添加 MAC 到白名单")
|
||||
def add_mac_to_whitelist(
|
||||
mac_address: str,
|
||||
description: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""添加 MAC 地址到白名单"""
|
||||
whitelist_mac = AlertService.add_mac_to_whitelist(
|
||||
db,
|
||||
mac_address=mac_address,
|
||||
description=description,
|
||||
owner=owner
|
||||
)
|
||||
return {"message": "MAC 已添加到白名单", "item": whitelist_mac}
|
||||
|
||||
|
||||
@router.delete("/whitelist/macs/{mac_id}", summary="从白名单移除 MAC")
|
||||
def remove_mac_from_whitelist(mac_id: int, db: Session = Depends(get_db)):
|
||||
"""从白名单移除 MAC 地址"""
|
||||
success = AlertService.remove_mac_from_whitelist(db, mac_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="MAC 不存在")
|
||||
return {"message": "MAC 已从白名单移除"}
|
||||
|
||||
|
||||
# ========== 单独检测接口 ==========
|
||||
|
||||
@router.post("/detect/ip-conflicts", summary="检测 IP 冲突")
|
||||
def detect_ip_conflicts(db: Session = Depends(get_db)):
|
||||
"""仅检测 IP 冲突"""
|
||||
conflicts = AlertService.detect_ip_conflicts(db)
|
||||
return {
|
||||
"count": len(conflicts),
|
||||
"conflicts": conflicts
|
||||
}
|
||||
|
||||
|
||||
@router.post("/detect/unauthorized", summary="检测未授权接入")
|
||||
def detect_unauthorized_access(db: Session = Depends(get_db)):
|
||||
"""仅检测未授权接入"""
|
||||
unauthorized = AlertService.detect_unauthorized_access(db)
|
||||
return {
|
||||
"count": len(unauthorized),
|
||||
"devices": unauthorized
|
||||
}
|
||||
|
||||
|
||||
@router.post("/detect/subnet-exhaustion", summary="检测网段耗尽")
|
||||
def detect_subnet_exhaustion(db: Session = Depends(get_db)):
|
||||
"""仅检测网段耗尽"""
|
||||
exhaustion = AlertService.detect_subnet_exhaustion(db)
|
||||
return {
|
||||
"count": len(exhaustion),
|
||||
"subnets": exhaustion
|
||||
}
|
||||
|
||||
|
||||
@router.post("/detect/new-devices", summary="检测新设备")
|
||||
def detect_new_devices(db: Session = Depends(get_db)):
|
||||
"""仅检测新发现的设备"""
|
||||
new_devices = AlertService.detect_new_devices(db)
|
||||
return {
|
||||
"count": len(new_devices),
|
||||
"devices": new_devices
|
||||
}
|
||||
|
||||
|
||||
@router.post("/detect/offline-devices", summary="检测离线设备")
|
||||
def detect_offline_devices(db: Session = Depends(get_db)):
|
||||
"""仅检测离线设备"""
|
||||
offline_devices = AlertService.detect_device_offline(db)
|
||||
return {
|
||||
"count": len(offline_devices),
|
||||
"devices": offline_devices
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.schemas.network import ScanTask
|
||||
from app.models.network import TaskStatus
|
||||
from app.services.network_service import NetworkService
|
||||
from app.services.scan_service import ScanService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/async-scan",
|
||||
tags=["异步扫描"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/network/{network_id}", response_model=ScanTask, summary="异步扫描网段")
|
||||
def async_scan_network(
|
||||
network_id: int,
|
||||
enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True,
|
||||
timeout: int = 2,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
提交异步网段扫描任务,立即返回任务ID,后台执行
|
||||
"""
|
||||
network = NetworkService.get_by_id(db, network_id)
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="网段不存在")
|
||||
|
||||
# 创建数据库任务记录
|
||||
db_task = ScanService.create_scan_task(db, 'ping', network_id)
|
||||
|
||||
# 提交 Celery 任务
|
||||
from app.tasks.celery_app import scan_network_task
|
||||
celery_task = scan_network_task.delay(
|
||||
network_id=network_id,
|
||||
enable_ping=enable_ping,
|
||||
enable_arp=enable_arp,
|
||||
enable_dns=enable_dns,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# 记录 Celery 任务ID
|
||||
db_task.celery_task_id = celery_task.id
|
||||
db.commit()
|
||||
db.refresh(db_task)
|
||||
|
||||
return db_task
|
||||
|
||||
|
||||
@router.post("/ip/{ip_address}", summary="异步扫描单个IP")
|
||||
def async_scan_ip(
|
||||
ip_address: str,
|
||||
enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True
|
||||
):
|
||||
"""
|
||||
提交异步单个IP扫描任务
|
||||
"""
|
||||
from app.tasks.celery_app import scan_single_ip_task
|
||||
celery_task = scan_single_ip_task.delay(
|
||||
ip_address=ip_address,
|
||||
enable_ping=enable_ping,
|
||||
enable_arp=enable_arp,
|
||||
enable_dns=enable_dns
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": celery_task.id,
|
||||
"ip_address": ip_address,
|
||||
"status": "pending"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/quick", summary="快速批量IP状态更新")
|
||||
def quick_scan_ips(ip_addresses: List[str]):
|
||||
"""
|
||||
快速批量更新IP状态(只做Ping+ARP,不做DNS)
|
||||
"""
|
||||
from app.tasks.celery_app import quick_status_update
|
||||
celery_task = quick_status_update.delay(ip_addresses=ip_addresses)
|
||||
|
||||
return {
|
||||
"task_id": celery_task.id,
|
||||
"ips_count": len(ip_addresses),
|
||||
"status": "pending"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/task/{task_id}", summary="查询异步任务状态")
|
||||
def get_async_task_status(task_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
根据 Celery 任务ID 查询任务状态
|
||||
"""
|
||||
# 先查询数据库任务
|
||||
from app.models.network import ScanTask as DbScanTask
|
||||
db_task = db.query(DbScanTask).filter(DbScanTask.celery_task_id == task_id).first()
|
||||
|
||||
# 直接查询 Celery
|
||||
from celery.result import AsyncResult
|
||||
result = AsyncResult(task_id)
|
||||
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"celery_status": result.status
|
||||
}
|
||||
|
||||
if db_task:
|
||||
response["db_status"] = db_task.status.value
|
||||
response["progress"] = db_task.progress
|
||||
response["total_count"] = db_task.total_count
|
||||
response["success_count"] = db_task.success_count
|
||||
response["started_at"] = db_task.started_at
|
||||
response["completed_at"] = db_task.completed_at
|
||||
|
||||
if result.status == 'SUCCESS':
|
||||
response["result"] = result.result
|
||||
elif result.status == 'FAILURE':
|
||||
response["error"] = str(result.info)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/tasks/running", summary="获取运行中的任务列表")
|
||||
def get_running_tasks(db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取所有正在运行的扫描任务
|
||||
"""
|
||||
from app.models.network import ScanTask as DbScanTask
|
||||
running = db.query(DbScanTask).filter(DbScanTask.status == TaskStatus.RUNNING).all()
|
||||
pending = db.query(DbScanTask).filter(DbScanTask.status == TaskStatus.PENDING).all()
|
||||
|
||||
return {
|
||||
"running_count": len(running),
|
||||
"pending_count": len(pending),
|
||||
"running_tasks": running,
|
||||
"pending_tasks": pending
|
||||
}
|
||||
|
||||
|
||||
@router.post("/task/{task_id}/cancel", summary="取消异步任务")
|
||||
def cancel_async_task(task_id: str):
|
||||
"""
|
||||
取消正在运行的异步任务
|
||||
"""
|
||||
from celery.result import AsyncResult
|
||||
result = AsyncResult(task_id)
|
||||
|
||||
if result.status in ['PENDING', 'RUNNING']:
|
||||
result.revoke(terminate=True)
|
||||
return {"status": "cancelled", "task_id": task_id}
|
||||
else:
|
||||
return {"status": "not_running", "current_status": result.status}
|
||||
|
||||
|
||||
@router.post("/full-scan", summary="触发全量扫描")
|
||||
def trigger_full_scan(
|
||||
enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True
|
||||
):
|
||||
"""
|
||||
触发全量网段扫描(所有网段)
|
||||
"""
|
||||
from app.tasks.celery_app import full_network_scan
|
||||
celery_task = full_network_scan.delay(
|
||||
enable_ping=enable_ping,
|
||||
enable_arp=enable_arp,
|
||||
enable_dns=enable_dns
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": celery_task.id,
|
||||
"status": "pending",
|
||||
"message": "全量扫描任务已提交"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/celery/inspect", summary="Celery Worker 状态检查")
|
||||
def inspect_celery():
|
||||
"""
|
||||
检查 Celery Worker 状态
|
||||
"""
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
try:
|
||||
inspector = celery_app.control.inspect()
|
||||
active = inspector.active()
|
||||
scheduled = inspector.scheduled()
|
||||
reserved = inspector.reserved()
|
||||
stats = inspector.stats()
|
||||
|
||||
return {
|
||||
"status": "healthy" if stats else "no_workers",
|
||||
"active_tasks": active,
|
||||
"scheduled_tasks": scheduled,
|
||||
"reserved_tasks": reserved,
|
||||
"worker_stats": stats
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"message": "Celery Worker 可能未启动"
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import csv
|
||||
import io
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.auth import User
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/audit",
|
||||
tags=["审计日志"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs", summary="查询审计日志")
|
||||
def get_audit_logs(
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
# 当前用户必须是 admin 或 super_admin 才能看审计
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
# 角色检查:super_admin / admin 可以看所有人的,operator/viewer 只能看自己的
|
||||
if current_user.role not in ("super_admin", "admin"):
|
||||
user_id = current_user.id
|
||||
|
||||
total, items = AuditService.get_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
status=status,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
serialized = []
|
||||
for log in items:
|
||||
serialized.append({
|
||||
"id": log.id,
|
||||
"user_id": log.user_id,
|
||||
"username": log.username,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"resource_name": log.resource_name,
|
||||
"method": log.method,
|
||||
"path": log.path,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"status": log.status,
|
||||
"detail": log.detail,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
})
|
||||
|
||||
return {"total": total, "items": serialized}
|
||||
|
||||
|
||||
@router.get("/logs/export", summary="导出审计日志为 CSV")
|
||||
def export_audit_logs(
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""导出 CSV 流。admin/super_admin 看全部;operator/viewer 仅能导出自己的。"""
|
||||
if current_user.role not in ("super_admin", "admin"):
|
||||
user_id = current_user.id
|
||||
|
||||
# 导出上限 50000 行,避免一次拉太多 OOM
|
||||
total, items = AuditService.get_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
status=status,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
skip=0,
|
||||
limit=50000,
|
||||
)
|
||||
|
||||
output = io.StringIO()
|
||||
# 写入 UTF-8 BOM 让 Excel 直接打开不乱码
|
||||
output.write("\ufeff")
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
"ID", "时间", "用户", "操作", "资源类型", "资源ID", "资源名称",
|
||||
"HTTP方法", "路径", "客户端IP", "状态", "详情"
|
||||
])
|
||||
for log in items:
|
||||
writer.writerow([
|
||||
log.id,
|
||||
log.created_at.isoformat() if log.created_at else "",
|
||||
log.username or "",
|
||||
log.action or "",
|
||||
log.resource_type or "",
|
||||
log.resource_id or "",
|
||||
log.resource_name or "",
|
||||
log.method or "",
|
||||
log.path or "",
|
||||
log.ip_address or "",
|
||||
log.status or "",
|
||||
(log.detail or "").replace("\n", " ")[:500],
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
filename = f"audit_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", summary="审计日志统计")
|
||||
def get_audit_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if current_user.role not in ("super_admin", "admin"):
|
||||
# operator/viewer 只能看自己的简单计数
|
||||
from app.models.audit import AuditLog
|
||||
from sqlalchemy import func
|
||||
my_count = db.query(func.count(AuditLog.id)).filter(AuditLog.user_id == current_user.id).scalar()
|
||||
return {"recent_24h": 0, "by_action_24h": {}, "my_total": int(my_count or 0)}
|
||||
|
||||
return AuditService.get_stats(db)
|
||||
|
||||
|
||||
@router.get("/actions", summary="获取所有审计操作类型(用于前端过滤下拉框)")
|
||||
def get_action_types():
|
||||
"""硬编码返回常用 action 列表,避免每次都查 DB"""
|
||||
from app.services.audit_service import AuditAction
|
||||
actions = []
|
||||
for name in dir(AuditAction):
|
||||
if name.startswith("_") or name.isupper() and not name.startswith("__"):
|
||||
continue
|
||||
val = getattr(AuditAction, name, None)
|
||||
if isinstance(val, str):
|
||||
actions.append({"code": val, "name": name})
|
||||
return actions
|
||||
@@ -0,0 +1,359 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user, require_permission
|
||||
from app.models.auth import User, UserRole, UserStatus
|
||||
from app.services.user_service import UserService, TokenService
|
||||
from app.services.audit_service import AuditService, AuditAction
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证管理"])
|
||||
|
||||
|
||||
@router.post("/login", summary="用户登录")
|
||||
def login(
|
||||
request: Request,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""用户登录,获取访问令牌"""
|
||||
user = UserService.authenticate(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
# 记录失败尝试
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.USER_LOGIN_FAILED,
|
||||
username=form_data.username,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
user_agent=(request.headers.get("user-agent") or "")[:500],
|
||||
status="failed",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 更新登录信息
|
||||
UserService.update_login_info(db, user.id, ip_address=request.client.host if request.client else None)
|
||||
|
||||
# 创建令牌
|
||||
tokens = TokenService.create_tokens(
|
||||
db, user,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=(request.headers.get("user-agent") or "")[:500],
|
||||
)
|
||||
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.USER_LOGIN,
|
||||
user=user,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
user_agent=(request.headers.get("user-agent") or "")[:500],
|
||||
)
|
||||
|
||||
return {
|
||||
**tokens,
|
||||
"user_info": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"real_name": user.real_name,
|
||||
"role": user.role.value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh", summary="刷新访问令牌")
|
||||
def refresh_token(refresh_token: str, db: Session = Depends(get_db)):
|
||||
"""使用刷新令牌获取新的访问令牌"""
|
||||
result = TokenService.refresh_access_token(db, refresh_token)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的刷新令牌或已过期",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/logout", summary="登出")
|
||||
def logout(
|
||||
request: Request,
|
||||
refresh_token: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""用户登出,撤销刷新令牌"""
|
||||
TokenService.revoke_token(db, refresh_token)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.USER_LOGOUT,
|
||||
user=current_user,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
user_agent=(request.headers.get("user-agent") or "")[:500],
|
||||
)
|
||||
return {"message": "登出成功"}
|
||||
|
||||
|
||||
@router.get("/me", summary="获取当前用户信息")
|
||||
def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||
"""获取当前登录用户信息"""
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"phone": current_user.phone,
|
||||
"real_name": current_user.real_name,
|
||||
"role": current_user.role.value,
|
||||
"status": current_user.status.value,
|
||||
"last_login_at": current_user.last_login_at,
|
||||
"email_notification": current_user.email_notification,
|
||||
"wechat_notification": current_user.wechat_notification,
|
||||
"dingtalk_notification": current_user.dingtalk_notification
|
||||
}
|
||||
|
||||
|
||||
@router.post("/change-password", summary="修改密码")
|
||||
def change_password(
|
||||
old_password: str,
|
||||
new_password: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""用户修改自己的密码"""
|
||||
if len(new_password) < 6:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="新密码长度不能少于6位"
|
||||
)
|
||||
|
||||
try:
|
||||
UserService.change_password(db, current_user.id, old_password, new_password)
|
||||
return {"message": "密码修改成功,请重新登录"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"密码修改失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ========== 用户管理 API (需要管理员权限) ==========
|
||||
|
||||
@router.get("/users", summary="获取用户列表")
|
||||
def get_users(
|
||||
status: Optional[UserStatus] = None,
|
||||
role: Optional[UserRole] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
current_user: User = Depends(require_permission("user:view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取用户列表(需要管理员权限)"""
|
||||
total, items = UserService.get_users(db, skip=skip, limit=limit, status=status, role=role)
|
||||
|
||||
# 返回脱敏的用户信息
|
||||
user_list = []
|
||||
for user in items:
|
||||
user_list.append({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"phone": user.phone,
|
||||
"real_name": user.real_name,
|
||||
"role": user.role.value,
|
||||
"status": user.status.value,
|
||||
"last_login_at": user.last_login_at,
|
||||
"created_at": user.created_at
|
||||
})
|
||||
|
||||
return {"total": total, "items": user_list}
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", summary="获取用户详情")
|
||||
def get_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(require_permission("user:view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取用户详情(需要管理员权限)"""
|
||||
user = UserService.get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"phone": user.phone,
|
||||
"real_name": user.real_name,
|
||||
"role": user.role.value,
|
||||
"status": user.status.value,
|
||||
"last_login_at": user.last_login_at,
|
||||
"last_login_ip": user.last_login_ip,
|
||||
"login_failed_count": user.login_failed_count,
|
||||
"created_at": user.created_at,
|
||||
"email_notification": user.email_notification,
|
||||
"wechat_notification": user.wechat_notification,
|
||||
"dingtalk_notification": user.dingtalk_notification
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users", summary="创建用户", status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
username: str,
|
||||
password: str,
|
||||
email: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
real_name: Optional[str] = None,
|
||||
role: UserRole = UserRole.VIEWER,
|
||||
current_user: User = Depends(require_permission("user:create")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""创建新用户(需要超级管理员权限)"""
|
||||
# 只有超级管理员才能创建管理员
|
||||
if role in [UserRole.SUPER_ADMIN, UserRole.ADMIN] and current_user.role != UserRole.SUPER_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权创建管理员账户"
|
||||
)
|
||||
|
||||
if len(password) < 6:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="密码长度不能少于6位"
|
||||
)
|
||||
|
||||
user = UserService.create_user(
|
||||
db,
|
||||
username=username,
|
||||
password=password,
|
||||
email=email,
|
||||
phone=phone,
|
||||
real_name=real_name,
|
||||
role=role,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "用户创建成功",
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role.value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/status", summary="更新用户状态")
|
||||
def update_user_status(
|
||||
user_id: int,
|
||||
status: UserStatus,
|
||||
current_user: User = Depends(require_permission("user:edit")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""启用/禁用用户"""
|
||||
# 不能禁用自己
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="不能禁用自己的账户"
|
||||
)
|
||||
|
||||
user = UserService.update_user_status(db, user_id, status)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
return {"message": "用户状态更新成功", "new_status": status.value}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/unlock", summary="解锁用户")
|
||||
def unlock_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(require_permission("user:edit")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""解锁被锁定的用户"""
|
||||
user = UserService.unlock_user(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
return {"message": "用户已解锁"}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-password", summary="重置用户密码")
|
||||
def reset_user_password(
|
||||
user_id: int,
|
||||
new_password: str,
|
||||
current_user: User = Depends(require_permission("user:edit")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""管理员重置用户密码"""
|
||||
if len(new_password) < 6:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="密码长度不能少于6位"
|
||||
)
|
||||
|
||||
# 只有超级管理员才能重置其他管理员的密码
|
||||
target_user = UserService.get_user_by_id(db, user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
if target_user.role in [UserRole.SUPER_ADMIN, UserRole.ADMIN] and current_user.role != UserRole.SUPER_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权重置管理员密码"
|
||||
)
|
||||
|
||||
success = UserService.reset_password(db, user_id, new_password)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 撤销该用户所有现有令牌,迫使其重新登录
|
||||
TokenService.revoke_all_user_tokens(db, user_id)
|
||||
|
||||
return {"message": "密码重置成功,用户所有会话已失效"}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", summary="删除用户")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(require_permission("user:delete")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除用户(软删除,标记为禁用)"""
|
||||
# 不能删除自己
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="不能删除自己的账户"
|
||||
)
|
||||
|
||||
# 只有超级管理员才能删除管理员
|
||||
target_user = UserService.get_user_by_id(db, user_id)
|
||||
if target_user and target_user.role in [UserRole.SUPER_ADMIN, UserRole.ADMIN]:
|
||||
if current_user.role != UserRole.SUPER_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权删除管理员账户"
|
||||
)
|
||||
|
||||
success = UserService.delete_user(db, user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 撤销所有令牌
|
||||
TokenService.revoke_all_user_tokens(db, user_id)
|
||||
|
||||
return {"message": "用户已删除"}
|
||||
@@ -0,0 +1,209 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.schemas.network import ScanTask, IPAddress
|
||||
from app.models.network import TaskType, TaskStatus
|
||||
from app.services.network_service import NetworkService
|
||||
from app.services.enhanced_scan_service import EnhancedScanService
|
||||
from app.services.scan_service import ScanService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/scan",
|
||||
tags=["增强扫描"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/comprehensive/{network_id}", response_model=ScanTask, summary="综合扫描网段")
|
||||
def comprehensive_scan(
|
||||
network_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True,
|
||||
timeout: int = 2,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
对指定网段执行综合扫描
|
||||
- Ping 扫描检测在线状态
|
||||
- ARP 扫描获取MAC地址
|
||||
- 反向DNS解析获取主机名
|
||||
- MAC厂商识别
|
||||
"""
|
||||
network = NetworkService.get_by_id(db, network_id)
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="网段不存在")
|
||||
|
||||
# 创建扫描任务
|
||||
task = ScanService.create_scan_task(db, TaskType.PING, network_id=network_id)
|
||||
|
||||
try:
|
||||
# 立即执行扫描(后续改为Celery异步)
|
||||
ScanService.update_task_status(db, task.id, TaskStatus.RUNNING, progress=0)
|
||||
|
||||
# 执行综合扫描
|
||||
results = EnhancedScanService.scan_network(
|
||||
network.cidr,
|
||||
enable_ping=enable_ping,
|
||||
enable_arp=enable_arp,
|
||||
enable_dns=enable_dns,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# 更新IP状态
|
||||
stats = EnhancedScanService.bulk_update_ips_from_scan(db, results)
|
||||
|
||||
# 刷新网段统计字段(used_ips/reserved_ips),保证网段列表利用率能即时反映
|
||||
NetworkService.get_stats(db, network_id)
|
||||
|
||||
# 更新任务状态
|
||||
ScanService.update_task_status(
|
||||
db,
|
||||
task.id,
|
||||
TaskStatus.COMPLETED,
|
||||
progress=100,
|
||||
total_count=stats['total_scanned'],
|
||||
success_count=stats['online_count']
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
ScanService.update_task_status(
|
||||
db,
|
||||
task.id,
|
||||
TaskStatus.FAILED,
|
||||
error_message=str(e)
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"扫描失败: {str(e)}")
|
||||
|
||||
# 重新获取任务状态
|
||||
task = ScanService.get_task_by_id(db, task.id)
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/comprehensive/ip/{ip_address}", summary="单个IP综合扫描")
|
||||
def comprehensive_scan_ip(
|
||||
ip_address: str,
|
||||
enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True,
|
||||
timeout: int = 2,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
对单个IP执行综合扫描
|
||||
"""
|
||||
result = EnhancedScanService.comprehensive_scan(
|
||||
ip_address,
|
||||
enable_ping=enable_ping,
|
||||
enable_arp=enable_arp,
|
||||
enable_dns=enable_dns,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# 如果IP在数据库中,更新信息
|
||||
EnhancedScanService.update_ip_from_scan_result(db, result)
|
||||
|
||||
# 刷新所属网段统计字段(used_ips/reserved_ips),保证网段列表利用率能即时反映
|
||||
db_ip = db.query(IPAddress).filter(IPAddress.ip_address == ip_address).first()
|
||||
if db_ip:
|
||||
NetworkService.get_stats(db, db_ip.network_id)
|
||||
|
||||
db.commit()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/mac/vendor/{mac_address}", summary="MAC地址厂商识别")
|
||||
def get_mac_vendor(mac_address: str):
|
||||
"""
|
||||
根据MAC地址识别厂商
|
||||
"""
|
||||
vendor = EnhancedScanService.get_mac_vendor(mac_address)
|
||||
return {
|
||||
"mac_address": mac_address,
|
||||
"vendor": vendor or "Unknown"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/dns/reverse/{ip_address}", summary="反向DNS解析")
|
||||
def reverse_dns(ip_address: str, timeout: int = 2):
|
||||
"""
|
||||
反向DNS解析获取主机名
|
||||
"""
|
||||
hostname = EnhancedScanService.reverse_dns_lookup(ip_address, timeout)
|
||||
return {
|
||||
"ip_address": ip_address,
|
||||
"hostname": hostname or "Unknown"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/online/ips", summary="获取所有在线IP列表")
|
||||
def get_online_ips(
|
||||
network_id: Optional[int] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取所有在线的IP地址列表
|
||||
"""
|
||||
from app.models.network import IPAddress as IPAddressModel, IPStatus
|
||||
|
||||
query = db.query(IPAddressModel).filter(IPAddressModel.status == IPStatus.ONLINE)
|
||||
|
||||
if network_id:
|
||||
query = query.filter(IPAddressModel.network_id == network_id)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(IPAddressModel.ip_address).offset(skip).limit(limit).all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"online_count": total,
|
||||
"items": items
|
||||
}
|
||||
|
||||
|
||||
@router.get("/statistics/summary", summary="扫描统计摘要")
|
||||
def get_scan_statistics(db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取扫描统计摘要信息
|
||||
"""
|
||||
from app.models.network import IPAddress as IPAddressModel, IPStatus
|
||||
from app.models.network import ScanTask, TaskStatus
|
||||
|
||||
# IP 统计
|
||||
total_ips = db.query(IPAddressModel).count()
|
||||
online_ips = db.query(IPAddressModel).filter(IPAddressModel.status == IPStatus.ONLINE).count()
|
||||
offline_ips = db.query(IPAddressModel).filter(IPAddressModel.status == IPStatus.OFFLINE).count()
|
||||
reserved_ips = db.query(IPAddressModel).filter(IPAddressModel.status == IPStatus.RESERVED).count()
|
||||
with_mac = db.query(IPAddressModel).filter(IPAddressModel.mac_address.isnot(None)).count()
|
||||
with_hostname = db.query(IPAddressModel).filter(IPAddressModel.hostname.isnot(None)).count()
|
||||
|
||||
# 任务统计
|
||||
total_tasks = db.query(ScanTask).count()
|
||||
completed_tasks = db.query(ScanTask).filter(ScanTask.status == TaskStatus.COMPLETED).count()
|
||||
failed_tasks = db.query(ScanTask).filter(ScanTask.status == TaskStatus.FAILED).count()
|
||||
|
||||
return {
|
||||
"ip_statistics": {
|
||||
"total": total_ips,
|
||||
"online": online_ips,
|
||||
"offline": offline_ips,
|
||||
"reserved": reserved_ips,
|
||||
"available": total_ips - online_ips - offline_ips - reserved_ips,
|
||||
"with_mac": with_mac,
|
||||
"with_hostname": with_hostname,
|
||||
"online_rate": round(online_ips / total_ips * 100, 2) if total_ips > 0 else 0
|
||||
},
|
||||
"task_statistics": {
|
||||
"total": total_tasks,
|
||||
"completed": completed_tasks,
|
||||
"failed": failed_tasks,
|
||||
"success_rate": round(completed_tasks / total_tasks * 100, 2) if total_tasks > 0 else 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.auth import User
|
||||
from app.schemas.network import IPAddressListResponse, IPAddressUpdate, IPAddress
|
||||
from app.models.network import IPStatus
|
||||
from app.services.ip_service import IPService
|
||||
from app.services.audit_service import AuditService, AuditAction
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/ips",
|
||||
tags=["IP地址管理"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
def _client_info(request: Request) -> tuple[str, str]:
|
||||
return (
|
||||
request.client.host if request.client else "",
|
||||
(request.headers.get("user-agent") or "")[:500],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=IPAddressListResponse, summary="获取IP列表")
|
||||
def get_ips(
|
||||
network_id: Optional[int] = None,
|
||||
status: Optional[IPStatus] = None,
|
||||
search: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取IP地址列表,支持过滤和搜索"""
|
||||
total, items = IPService.get_list(
|
||||
db,
|
||||
network_id=network_id,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
search=search
|
||||
)
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@router.get("/{ip_id}", response_model=IPAddress, summary="获取IP详情")
|
||||
def get_ip(ip_id: int, db: Session = Depends(get_db)):
|
||||
"""根据ID获取IP详情"""
|
||||
ip = IPService.get_by_id(db, ip_id)
|
||||
if not ip:
|
||||
raise HTTPException(status_code=404, detail="IP不存在")
|
||||
return ip
|
||||
|
||||
|
||||
@router.get("/address/{ip_address}", response_model=IPAddress, summary="根据IP地址查询")
|
||||
def get_ip_by_address(ip_address: str, db: Session = Depends(get_db)):
|
||||
"""根据IP地址查询详细信息"""
|
||||
ip = IPService.get_by_ip(db, ip_address)
|
||||
if not ip:
|
||||
raise HTTPException(status_code=404, detail="IP不存在")
|
||||
return ip
|
||||
|
||||
|
||||
@router.put("/{ip_id}", response_model=IPAddress, summary="更新IP信息")
|
||||
def update_ip(
|
||||
ip_id: int,
|
||||
ip_in: IPAddressUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新IP地址的属性信息"""
|
||||
old = IPService.get_by_id(db, ip_id)
|
||||
if not old:
|
||||
raise HTTPException(status_code=404, detail="IP不存在")
|
||||
|
||||
ip = IPService.update(db, ip_id, ip_in)
|
||||
if not ip:
|
||||
raise HTTPException(status_code=404, detail="IP不存在")
|
||||
|
||||
c, u = _client_info(request)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.IP_UPDATE,
|
||||
user=current_user,
|
||||
resource_type="ip",
|
||||
resource_id=str(ip.id),
|
||||
resource_name=ip.ip_address,
|
||||
method="PUT",
|
||||
path=str(request.url.path),
|
||||
ip_address=c,
|
||||
user_agent=u,
|
||||
detail={
|
||||
"before": {
|
||||
"owner": old.owner, "hostname": old.hostname, "mac_address": old.mac_address,
|
||||
"vendor": old.vendor, "status": old.status.value if old.status else None,
|
||||
"notes": old.notes, "switch_name": old.switch_name, "switch_port": old.switch_port,
|
||||
},
|
||||
"after": {
|
||||
"owner": ip.owner, "hostname": ip.hostname, "mac_address": ip.mac_address,
|
||||
"vendor": ip.vendor, "status": ip.status.value if ip.status else None,
|
||||
"notes": ip.notes, "switch_name": ip.switch_name, "switch_port": ip.switch_port,
|
||||
},
|
||||
},
|
||||
)
|
||||
return ip
|
||||
|
||||
|
||||
@router.post("/{ip_id}/reserve", response_model=IPAddress, summary="标记为保留IP")
|
||||
def reserve_ip(
|
||||
ip_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""将IP标记为保留状态,禁止分配"""
|
||||
ip = IPService.set_reserved(db, ip_id, reserved=True)
|
||||
if not ip:
|
||||
raise HTTPException(status_code=404, detail="IP不存在")
|
||||
c, u = _client_info(request)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.IP_RESERVE,
|
||||
user=current_user,
|
||||
resource_type="ip",
|
||||
resource_id=str(ip.id),
|
||||
resource_name=ip.ip_address,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
ip_address=c,
|
||||
user_agent=u,
|
||||
)
|
||||
return ip
|
||||
|
||||
|
||||
@router.post("/{ip_id}/unreserve", response_model=IPAddress, summary="取消保留IP")
|
||||
def unreserve_ip(
|
||||
ip_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""取消IP的保留状态"""
|
||||
ip = IPService.set_reserved(db, ip_id, reserved=False)
|
||||
if not ip:
|
||||
raise HTTPException(status_code=404, detail="IP不存在")
|
||||
c, u = _client_info(request)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.IP_UNRESERVE,
|
||||
user=current_user,
|
||||
resource_type="ip",
|
||||
resource_id=str(ip.id),
|
||||
resource_name=ip.ip_address,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
ip_address=c,
|
||||
user_agent=u,
|
||||
)
|
||||
return ip
|
||||
@@ -0,0 +1,180 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.auth import User
|
||||
from app.schemas.network import (
|
||||
NetworkCreate, NetworkUpdate, Network, NetworkListResponse,
|
||||
NetworkStats, IPAddressListResponse, IPAddressUpdate, IPAddress
|
||||
)
|
||||
from app.services.network_service import NetworkService
|
||||
from app.services.audit_service import AuditService, AuditAction
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/networks",
|
||||
tags=["网段管理"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
def _client_info(request: Request) -> tuple[str, str]:
|
||||
return (
|
||||
request.client.host if request.client else "",
|
||||
(request.headers.get("user-agent") or "")[:500],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=NetworkListResponse, summary="获取网段列表")
|
||||
def get_networks(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
group_name: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取所有网段列表"""
|
||||
total, items = NetworkService.get_list(db, skip=skip, limit=limit, group_name=group_name)
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@router.get("/stats", response_model=list[NetworkStats], summary="获取所有网段统计")
|
||||
def get_all_network_stats(db: Session = Depends(get_db)):
|
||||
"""获取所有网段的统计信息"""
|
||||
return NetworkService.get_all_stats(db)
|
||||
|
||||
|
||||
@router.get("/{network_id}", response_model=Network, summary="获取网段详情")
|
||||
def get_network(network_id: int, db: Session = Depends(get_db)):
|
||||
"""根据ID获取网段详情"""
|
||||
network = NetworkService.get_by_id(db, network_id)
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="网段不存在")
|
||||
return network
|
||||
|
||||
|
||||
@router.get("/{network_id}/stats", response_model=NetworkStats, summary="获取网段统计")
|
||||
def get_network_stats(network_id: int, db: Session = Depends(get_db)):
|
||||
"""获取指定网段的统计信息"""
|
||||
stats = NetworkService.get_stats(db, network_id)
|
||||
if not stats:
|
||||
raise HTTPException(status_code=404, detail="网段不存在")
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/{network_id}/ips", response_model=IPAddressListResponse, summary="获取网段下的IP列表")
|
||||
def get_network_ips(
|
||||
network_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 200,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取指定网段下的所有IP地址"""
|
||||
from app.services.ip_service import IPService
|
||||
total, items = IPService.get_list(db, network_id=network_id, skip=skip, limit=limit)
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@router.post("", response_model=Network, status_code=201, summary="创建网段")
|
||||
def create_network(
|
||||
network_in: NetworkCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""创建新网段,支持CIDR格式"""
|
||||
existing = NetworkService.get_by_cidr(db, network_in.cidr)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="该网段已存在")
|
||||
|
||||
ip, ua = _client_info(request)
|
||||
try:
|
||||
network = NetworkService.create(db, network_in)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.NETWORK_CREATE,
|
||||
user=current_user,
|
||||
resource_type="network",
|
||||
resource_id=network.id,
|
||||
resource_name=network.cidr,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
detail={"cidr": network.cidr, "name": network.name},
|
||||
)
|
||||
return network
|
||||
|
||||
|
||||
@router.put("/{network_id}", response_model=Network, summary="更新网段")
|
||||
def update_network(
|
||||
network_id: int,
|
||||
network_in: NetworkUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新网段信息"""
|
||||
old = NetworkService.get_by_id(db, network_id)
|
||||
if not old:
|
||||
raise HTTPException(status_code=404, detail="网段不存在")
|
||||
|
||||
network = NetworkService.update(db, network_id, network_in)
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="网段不存在")
|
||||
|
||||
ip, ua = _client_info(request)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.NETWORK_UPDATE,
|
||||
user=current_user,
|
||||
resource_type="network",
|
||||
resource_id=network.id,
|
||||
resource_name=network.cidr,
|
||||
method="PUT",
|
||||
path=str(request.url.path),
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
detail={
|
||||
"before": {"name": old.name, "gateway": old.gateway, "group_name": old.group_name, "vlan_id": old.vlan_id, "description": old.description},
|
||||
"after": {"name": network.name, "gateway": network.gateway, "group_name": network.group_name, "vlan_id": network.vlan_id, "description": network.description},
|
||||
},
|
||||
)
|
||||
return network
|
||||
|
||||
|
||||
@router.delete("/{network_id}", summary="删除网段")
|
||||
def delete_network(
|
||||
network_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""删除网段及其下的所有IP记录"""
|
||||
network = NetworkService.get_by_id(db, network_id)
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="网段不存在")
|
||||
cidr_snapshot = network.cidr
|
||||
|
||||
success = NetworkService.delete(db, network_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="网段不存在")
|
||||
|
||||
ip, ua = _client_info(request)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.NETWORK_DELETE,
|
||||
user=current_user,
|
||||
resource_type="network",
|
||||
resource_id=network_id,
|
||||
resource_name=cidr_snapshot,
|
||||
method="DELETE",
|
||||
path=str(request.url.path),
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
detail={"cidr": cidr_snapshot},
|
||||
)
|
||||
return {"message": "删除成功"}
|
||||
@@ -0,0 +1,108 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.schemas.network import ScanTask, ScanTaskListResponse, ScanResult
|
||||
from app.models.network import TaskType, TaskStatus
|
||||
from app.services.scan_service import ScanService
|
||||
from app.services.network_service import NetworkService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/scan",
|
||||
tags=["扫描管理"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ScanTaskListResponse, summary="获取扫描任务列表")
|
||||
def get_scan_tasks(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取所有扫描任务"""
|
||||
total, items = ScanService.get_task_list(db, skip=skip, limit=limit)
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=ScanTask, summary="获取扫描任务状态")
|
||||
def get_scan_task(task_id: int, db: Session = Depends(get_db)):
|
||||
"""获取指定扫描任务的状态"""
|
||||
task = ScanService.get_task_by_id(db, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/ping/{network_id}", response_model=ScanTask, status_code=201, summary="触发网段Ping扫描")
|
||||
def trigger_ping_scan(network_id: int, db: Session = Depends(get_db)):
|
||||
"""对指定网段触发ICMP Ping扫描"""
|
||||
network = NetworkService.get_by_id(db, network_id)
|
||||
if not network:
|
||||
raise HTTPException(status_code=404, detail="网段不存在")
|
||||
|
||||
# 创建扫描任务
|
||||
task = ScanService.create_scan_task(db, TaskType.PING, network_id=network_id)
|
||||
|
||||
# 立即执行扫描(简单版,后续用Celery)
|
||||
try:
|
||||
ScanService.update_task_status(db, task.id, TaskStatus.RUNNING, progress=0)
|
||||
|
||||
# 执行扫描
|
||||
results = ScanService.ping_network(network.cidr)
|
||||
|
||||
# 更新IP状态
|
||||
online_count, offline_count = ScanService.update_ip_status_from_scan_results(db, results)
|
||||
|
||||
# 刷新网段统计字段(used_ips/reserved_ips),保证网段列表利用率能即时反映
|
||||
NetworkService.get_stats(db, network_id)
|
||||
|
||||
# 更新任务状态
|
||||
ScanService.update_task_status(
|
||||
db,
|
||||
task.id,
|
||||
TaskStatus.COMPLETED,
|
||||
progress=100,
|
||||
total_count=len(results),
|
||||
success_count=online_count,
|
||||
failed_count=offline_count
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
ScanService.update_task_status(
|
||||
db,
|
||||
task.id,
|
||||
TaskStatus.FAILED,
|
||||
error_message=str(e)
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"扫描失败: {str(e)}")
|
||||
|
||||
# 重新获取任务状态
|
||||
task = ScanService.get_task_by_id(db, task.id)
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/ping/single/{ip_address}", response_model=ScanResult, summary="单个IP Ping测试")
|
||||
def ping_single_ip(ip_address: str):
|
||||
"""对单个IP执行Ping测试"""
|
||||
result = ScanService.ping_single_ip(ip_address)
|
||||
return {
|
||||
"ip_address": result["ip_address"],
|
||||
"status": result["status"],
|
||||
"mac_address": None,
|
||||
"hostname": None,
|
||||
"response_time": result.get("response_time")
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh/stats", summary="刷新所有网段统计")
|
||||
def refresh_all_stats(db: Session = Depends(get_db)):
|
||||
"""刷新所有网段的统计数据"""
|
||||
stats = NetworkService.get_all_stats(db)
|
||||
return {
|
||||
"message": "统计更新完成",
|
||||
"networks_updated": len(stats),
|
||||
"stats": stats
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.auth import User
|
||||
from app.services.snmp_service import SNMPService
|
||||
from app.services.audit_service import AuditService, AuditAction
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/snmp",
|
||||
tags=["SNMP 管理"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
def _client_info(request: Request) -> tuple[str, str]:
|
||||
return (
|
||||
request.client.host if request.client else "",
|
||||
(request.headers.get("user-agent") or "")[:500],
|
||||
)
|
||||
|
||||
|
||||
# ========== 凭据管理 ==========
|
||||
|
||||
@router.get("/credentials", summary="获取 SNMP 凭据列表")
|
||||
def get_snmp_credentials(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
from app.models.snmp import SNMPCredential
|
||||
query = db.query(SNMPCredential)
|
||||
total = query.count()
|
||||
items = query.order_by(SNMPCredential.id.desc()).offset(skip).limit(limit).all()
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@router.get("/credentials/{credential_id}", summary="获取凭据详情")
|
||||
def get_snmp_credential(credential_id: int, db: Session = Depends(get_db)):
|
||||
from app.models.snmp import SNMPCredential
|
||||
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
|
||||
if not credential:
|
||||
raise HTTPException(status_code=404, detail="凭据不存在")
|
||||
return credential
|
||||
|
||||
|
||||
@router.post("/credentials", summary="创建 SNMP 凭据")
|
||||
def create_snmp_credential(
|
||||
name: str,
|
||||
version: str,
|
||||
community_string: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
auth_password: Optional[str] = None,
|
||||
auth_protocol: Optional[str] = None,
|
||||
priv_password: Optional[str] = None,
|
||||
priv_protocol: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
timeout: int = 3,
|
||||
retries: int = 2,
|
||||
request: Request = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.models.snmp import SNMPCredential
|
||||
|
||||
existing = db.query(SNMPCredential).filter(SNMPCredential.name == name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="凭据名称已存在")
|
||||
|
||||
credential = SNMPCredential(
|
||||
name=name,
|
||||
version=version,
|
||||
community_string=community_string,
|
||||
username=username,
|
||||
auth_password=auth_password,
|
||||
auth_protocol=auth_protocol,
|
||||
priv_password=priv_password,
|
||||
priv_protocol=priv_protocol,
|
||||
description=description,
|
||||
timeout=timeout,
|
||||
retries=retries
|
||||
)
|
||||
db.add(credential)
|
||||
db.commit()
|
||||
db.refresh(credential)
|
||||
|
||||
c, u = _client_info(request)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.SNMP_CRED_CREATE,
|
||||
user=current_user,
|
||||
resource_type="snmp_credential",
|
||||
resource_id=credential.id,
|
||||
resource_name=credential.name,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
ip_address=c,
|
||||
user_agent=u,
|
||||
detail={"version": credential.version, "name": credential.name},
|
||||
)
|
||||
return credential
|
||||
|
||||
|
||||
@router.put("/credentials/{credential_id}", summary="更新 SNMP 凭据")
|
||||
def update_snmp_credential(
|
||||
credential_id: int,
|
||||
name: Optional[str] = None,
|
||||
community_string: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
auth_password: Optional[str] = None,
|
||||
auth_protocol: Optional[str] = None,
|
||||
priv_password: Optional[str] = None,
|
||||
priv_protocol: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
retries: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
request: Request = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.models.snmp import SNMPCredential
|
||||
|
||||
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
|
||||
if not credential:
|
||||
raise HTTPException(status_code=404, detail="凭据不存在")
|
||||
|
||||
before = {"name": credential.name, "description": credential.description, "is_active": credential.is_active, "timeout": credential.timeout, "retries": credential.retries}
|
||||
|
||||
if name:
|
||||
credential.name = name
|
||||
if community_string is not None:
|
||||
credential.community_string = community_string
|
||||
if username is not None:
|
||||
credential.username = username
|
||||
if auth_password is not None:
|
||||
credential.auth_password = auth_password
|
||||
if auth_protocol is not None:
|
||||
credential.auth_protocol = auth_protocol
|
||||
if priv_password is not None:
|
||||
credential.priv_password = priv_password
|
||||
if priv_protocol is not None:
|
||||
credential.priv_protocol = priv_protocol
|
||||
if description is not None:
|
||||
credential.description = description
|
||||
if timeout is not None:
|
||||
credential.timeout = timeout
|
||||
if retries is not None:
|
||||
credential.retries = retries
|
||||
if is_active is not None:
|
||||
credential.is_active = is_active
|
||||
|
||||
db.commit()
|
||||
db.refresh(credential)
|
||||
|
||||
c, u = _client_info(request)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.SNMP_CRED_UPDATE,
|
||||
user=current_user,
|
||||
resource_type="snmp_credential",
|
||||
resource_id=credential.id,
|
||||
resource_name=credential.name,
|
||||
method="PUT",
|
||||
path=str(request.url.path),
|
||||
ip_address=c,
|
||||
user_agent=u,
|
||||
detail={
|
||||
"before": before,
|
||||
"after": {"name": credential.name, "description": credential.description, "is_active": credential.is_active, "timeout": credential.timeout, "retries": credential.retries},
|
||||
},
|
||||
)
|
||||
return credential
|
||||
|
||||
|
||||
@router.delete("/credentials/{credential_id}", summary="删除 SNMP 凭据")
|
||||
def delete_snmp_credential(
|
||||
credential_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.models.snmp import SNMPCredential, NetworkDevice
|
||||
|
||||
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
|
||||
if not credential:
|
||||
raise HTTPException(status_code=404, detail="凭据不存在")
|
||||
|
||||
devices = db.query(NetworkDevice).filter(NetworkDevice.snmp_credential_id == credential_id).count()
|
||||
if devices > 0:
|
||||
raise HTTPException(status_code=400, detail=f"有 {devices} 个设备正在使用此凭据,无法删除")
|
||||
|
||||
name_snapshot = credential.name
|
||||
db.delete(credential)
|
||||
db.commit()
|
||||
|
||||
c, u = _client_info(request)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.SNMP_CRED_DELETE,
|
||||
user=current_user,
|
||||
resource_type="snmp_credential",
|
||||
resource_id=credential_id,
|
||||
resource_name=name_snapshot,
|
||||
method="DELETE",
|
||||
path=str(request.url.path),
|
||||
ip_address=c,
|
||||
user_agent=u,
|
||||
)
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
# ========== 设备管理 ==========
|
||||
|
||||
@router.get("/devices", summary="获取网络设备列表")
|
||||
def get_network_devices(
|
||||
device_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
from app.models.snmp import NetworkDevice, SNMPCredential
|
||||
|
||||
query = db.query(NetworkDevice)
|
||||
|
||||
if device_type:
|
||||
query = query.filter(NetworkDevice.device_type == device_type)
|
||||
if is_active is not None:
|
||||
query = query.filter(NetworkDevice.is_active == is_active)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(NetworkDevice.id.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
# 附带凭据名称
|
||||
credential_ids = {d.snmp_credential_id for d in items if d.snmp_credential_id}
|
||||
cred_map = {}
|
||||
if credential_ids:
|
||||
for c in db.query(SNMPCredential).filter(SNMPCredential.id.in_(credential_ids)).all():
|
||||
cred_map[c.id] = c.name
|
||||
|
||||
serialized = []
|
||||
for d in items:
|
||||
item = {
|
||||
"id": d.id,
|
||||
"name": d.name,
|
||||
"description": d.description,
|
||||
"ip_address": d.ip_address,
|
||||
"port": d.port,
|
||||
"device_type": d.device_type.value if hasattr(d.device_type, 'value') else d.device_type,
|
||||
"vendor": d.vendor,
|
||||
"model": d.model,
|
||||
"firmware_version": d.firmware_version,
|
||||
"serial_number": d.serial_number,
|
||||
"location": d.location,
|
||||
"snmp_credential_id": d.snmp_credential_id,
|
||||
"credential_name": cred_map.get(d.snmp_credential_id),
|
||||
"is_active": d.is_active,
|
||||
"last_polled_at": d.last_polled_at.isoformat() if d.last_polled_at else None,
|
||||
"last_successful_poll": d.last_successful_poll.isoformat() if d.last_successful_poll else None,
|
||||
"arp_poll_interval": d.arp_poll_interval,
|
||||
"mac_poll_interval": d.mac_poll_interval,
|
||||
"interface_poll_interval": d.interface_poll_interval,
|
||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
||||
}
|
||||
serialized.append(item)
|
||||
|
||||
return {"total": total, "items": serialized}
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}", summary="获取网络设备详情")
|
||||
def get_network_device(device_id: int, db: Session = Depends(get_db)):
|
||||
from app.models.snmp import NetworkDevice
|
||||
|
||||
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
return device
|
||||
|
||||
|
||||
@router.post("/devices", summary="创建网络设备")
|
||||
def create_network_device(
|
||||
name: str,
|
||||
ip_address: str,
|
||||
credential_id: Optional[int] = None,
|
||||
port: int = 161,
|
||||
device_type: str = "other",
|
||||
description: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
request: Request = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.models.snmp import NetworkDevice
|
||||
|
||||
existing = db.query(NetworkDevice).filter(NetworkDevice.name == name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="设备名称已存在")
|
||||
|
||||
device = NetworkDevice(
|
||||
name=name,
|
||||
ip_address=ip_address,
|
||||
port=port,
|
||||
device_type=device_type,
|
||||
snmp_credential_id=credential_id,
|
||||
description=description,
|
||||
location=location,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.add(device)
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
|
||||
c, u = _client_info(request)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.SNMP_DEVICE_CREATE,
|
||||
user=current_user,
|
||||
resource_type="snmp_device",
|
||||
resource_id=device.id,
|
||||
resource_name=device.name,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
ip_address=c,
|
||||
user_agent=u,
|
||||
detail={"ip_address": device.ip_address, "port": device.port, "device_type": device.device_type.value if hasattr(device.device_type, 'value') else device.device_type},
|
||||
)
|
||||
return device
|
||||
|
||||
|
||||
@router.put("/devices/{device_id}", summary="更新网络设备")
|
||||
def update_network_device(
|
||||
device_id: int,
|
||||
name: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
credential_id: Optional[int] = None,
|
||||
port: Optional[int] = None,
|
||||
device_type: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
request: Request = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.models.snmp import NetworkDevice
|
||||
|
||||
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
before = {
|
||||
"name": device.name, "ip_address": device.ip_address, "port": device.port,
|
||||
"snmp_credential_id": device.snmp_credential_id, "device_type": device.device_type.value if hasattr(device.device_type, 'value') else device.device_type,
|
||||
"description": device.description, "location": device.location, "is_active": device.is_active,
|
||||
}
|
||||
|
||||
if name:
|
||||
device.name = name
|
||||
if ip_address:
|
||||
device.ip_address = ip_address
|
||||
if credential_id is not None:
|
||||
device.snmp_credential_id = credential_id
|
||||
if port:
|
||||
device.port = port
|
||||
if device_type:
|
||||
device.device_type = device_type
|
||||
if description is not None:
|
||||
device.description = description
|
||||
if location is not None:
|
||||
device.location = location
|
||||
if is_active is not None:
|
||||
device.is_active = is_active
|
||||
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
|
||||
c, u = _client_info(request)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.SNMP_DEVICE_UPDATE,
|
||||
user=current_user,
|
||||
resource_type="snmp_device",
|
||||
resource_id=device.id,
|
||||
resource_name=device.name,
|
||||
method="PUT",
|
||||
path=str(request.url.path),
|
||||
ip_address=c,
|
||||
user_agent=u,
|
||||
detail={
|
||||
"before": before,
|
||||
"after": {
|
||||
"name": device.name, "ip_address": device.ip_address, "port": device.port,
|
||||
"snmp_credential_id": device.snmp_credential_id, "device_type": device.device_type.value if hasattr(device.device_type, 'value') else device.device_type,
|
||||
"description": device.description, "location": device.location, "is_active": device.is_active,
|
||||
},
|
||||
},
|
||||
)
|
||||
return device
|
||||
|
||||
|
||||
@router.delete("/devices/{device_id}", summary="删除网络设备")
|
||||
def delete_network_device(
|
||||
device_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.models.snmp import NetworkDevice
|
||||
|
||||
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
name_snapshot = device.name
|
||||
ip_snapshot = device.ip_address
|
||||
|
||||
db.delete(device)
|
||||
db.commit()
|
||||
|
||||
c, u = _client_info(request)
|
||||
AuditService.record(
|
||||
db,
|
||||
action=AuditAction.SNMP_DEVICE_DELETE,
|
||||
user=current_user,
|
||||
resource_type="snmp_device",
|
||||
resource_id=device_id,
|
||||
resource_name=name_snapshot,
|
||||
method="DELETE",
|
||||
path=str(request.url.path),
|
||||
ip_address=c,
|
||||
user_agent=u,
|
||||
detail={"ip_address": ip_snapshot},
|
||||
)
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
# ========== SNMP 操作 ==========
|
||||
|
||||
@router.post("/devices/{device_id}/test", summary="测试 SNMP 连接")
|
||||
def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
|
||||
"""测试设备的 SNMP 连接(不写审计,避免刷屏)"""
|
||||
from app.models.snmp import NetworkDevice
|
||||
|
||||
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
if not device.snmp_credential:
|
||||
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
|
||||
|
||||
success, info = SNMPService.test_connection(device, device.snmp_credential)
|
||||
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"device_name": device.name,
|
||||
"success": success,
|
||||
"info": info
|
||||
}
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/poll", summary="立即轮询设备")
|
||||
def poll_device_now(device_id: int, db: Session = Depends(get_db)):
|
||||
"""立即轮询设备,获取 ARP 表、MAC 地址表、接口信息(不写审计,常规操作)"""
|
||||
result = SNMPService.poll_device(db, device_id)
|
||||
|
||||
if 'error' in result:
|
||||
raise HTTPException(status_code=400, detail=result['error'])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/arp", summary="获取设备 ARP 表")
|
||||
def get_device_arp_table(device_id: int, db: Session = Depends(get_db)):
|
||||
"""获取设备的 ARP 表"""
|
||||
from app.models.snmp import NetworkDevice, ARPEntry
|
||||
|
||||
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
if not device.snmp_credential:
|
||||
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
|
||||
|
||||
entries = SNMPService.get_arp_table(device, device.snmp_credential, db)
|
||||
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"device_name": device.name,
|
||||
"entries": entries,
|
||||
"count": len(entries)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/arp-entries", summary="查询所有 ARP 记录")
|
||||
def get_all_arp_entries(
|
||||
device_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
mac_address: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""查询 ARP 表数据"""
|
||||
from app.models.snmp import ARPEntry
|
||||
|
||||
query = db.query(ARPEntry)
|
||||
|
||||
if device_id:
|
||||
query = query.filter(ARPEntry.device_id == device_id)
|
||||
if ip_address:
|
||||
query = query.filter(ARPEntry.ip_address.like(f'%{ip_address}%'))
|
||||
if mac_address:
|
||||
query = query.filter(ARPEntry.mac_address.like(f'%{mac_address}%'))
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(ARPEntry.last_seen.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@router.get("/statistics/summary", summary="SNMP 统计摘要")
|
||||
def get_snmp_statistics(db: Session = Depends(get_db)):
|
||||
"""获取 SNMP 相关统计"""
|
||||
from app.models.snmp import SNMPCredential, NetworkDevice, ARPEntry
|
||||
|
||||
credential_count = db.query(SNMPCredential).filter(SNMPCredential.is_active == True).count()
|
||||
device_count = db.query(NetworkDevice).filter(NetworkDevice.is_active == True).count()
|
||||
arp_count = db.query(ARPEntry).count()
|
||||
unique_macs = db.query(ARPEntry.mac_address).distinct().count()
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
||||
online_devices = db.query(NetworkDevice).filter(
|
||||
NetworkDevice.is_active == True,
|
||||
NetworkDevice.last_successful_poll >= one_hour_ago
|
||||
).count()
|
||||
|
||||
return {
|
||||
"credentials": {
|
||||
"total": credential_count
|
||||
},
|
||||
"devices": {
|
||||
"total": device_count,
|
||||
"online_last_hour": online_devices
|
||||
},
|
||||
"data": {
|
||||
"arp_entries": arp_count,
|
||||
"unique_mac_addresses": unique_macs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# app/core/__init__.py
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, engine, get_db, SessionLocal
|
||||
|
||||
__all__ = ["settings", "Base", "engine", "get_db", "SessionLocal"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,35 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# 应用配置
|
||||
APP_NAME: str = "IPAM Management System"
|
||||
APP_VERSION: str = "0.1.0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL: str = "mysql+pymysql://ipam:***@localhost:3308/ipam"
|
||||
|
||||
# Redis配置
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# Celery配置
|
||||
CELERY_BROKER_URL: str = "redis://localhost:6379/1"
|
||||
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/2"
|
||||
|
||||
# JWT配置
|
||||
SECRET_KEY: str = "your-super-secret-key-here-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7
|
||||
|
||||
# 扫描配置
|
||||
PING_TIMEOUT: int = 2
|
||||
PING_RETRIES: int = 2
|
||||
SCAN_CONCURRENCY: int = 50
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
# 创建数据库引擎
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
echo=settings.DEBUG
|
||||
)
|
||||
|
||||
# 创建会话工厂
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# 创建基础模型类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""
|
||||
获取数据库会话的依赖项
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,158 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.auth import User, UserStatus
|
||||
|
||||
# 密码加密上下文
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# OAuth2 认证方案
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login")
|
||||
|
||||
|
||||
class SecurityUtils:
|
||||
"""安全工具类"""
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""密码哈希加密"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证密码"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""创建访问令牌"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
@staticmethod
|
||||
def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""创建刷新令牌"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(days=7) # 刷新令牌7天过期
|
||||
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
@staticmethod
|
||||
def verify_token(token: str, credentials_exception=None) -> Optional[dict]:
|
||||
"""验证令牌"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
token_type: str = payload.get("type")
|
||||
|
||||
if username is None:
|
||||
raise credentials_exception or HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭据",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
raise credentials_exception or HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭据",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
# 权限校验依赖
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
|
||||
"""获取当前登录用户"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无法验证凭据",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = SecurityUtils.verify_token(token, credentials_exception)
|
||||
username: str = payload.get("sub")
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
# 检查用户状态
|
||||
if user.status != UserStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="用户已被禁用或锁定"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""获取当前活跃用户(已废弃,保留兼容)"""
|
||||
return current_user
|
||||
|
||||
|
||||
class PermissionChecker:
|
||||
"""权限检查器"""
|
||||
|
||||
def __init__(self, required_permission: str):
|
||||
self.required_permission = required_permission
|
||||
|
||||
def __call__(self, current_user: User = Depends(get_current_user)) -> User:
|
||||
"""检查用户是否拥有指定权限"""
|
||||
# 超级管理员拥有所有权限
|
||||
if current_user.role == "super_admin":
|
||||
return current_user
|
||||
|
||||
# 基于角色的权限检查
|
||||
role_permissions = {
|
||||
"admin": {
|
||||
"network", "ip", "scan", "snmp", "alert", "report"
|
||||
},
|
||||
"operator": {
|
||||
"network:view", "ip:view", "ip:edit", "scan:run", "alert:view"
|
||||
},
|
||||
"viewer": {
|
||||
"network:view", "ip:view", "alert:view"
|
||||
}
|
||||
}
|
||||
|
||||
user_permissions = role_permissions.get(current_user.role, set())
|
||||
|
||||
# 检查是否有通配符权限(如 "network" 包含 "network:create")
|
||||
permission_category = self.required_permission.split(":")[0]
|
||||
if (
|
||||
self.required_permission not in user_permissions and
|
||||
permission_category not in user_permissions and
|
||||
"*" not in user_permissions
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"缺少权限: {self.required_permission}"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
# 快捷权限检查依赖
|
||||
def require_permission(permission: str):
|
||||
"""权限检查依赖函数"""
|
||||
return PermissionChecker(permission)
|
||||
@@ -0,0 +1,71 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import engine, Base, SessionLocal
|
||||
from app.api.v1 import api_router
|
||||
|
||||
# 导入所有模型确保创建表
|
||||
from app.models.network import Network, IPAddress, ScanTask
|
||||
from app.models.snmp import SNMPCredential, NetworkDevice, ARPEntry, MACAddressTable, DeviceInterface
|
||||
from app.models.alert import Alert, AlertRule, WhitelistedMAC
|
||||
from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken
|
||||
from app.models.audit import AuditLog
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 启动时创建数据库表
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 初始化默认管理员账户 + 默认权限
|
||||
from app.services.user_service import UserService
|
||||
db = SessionLocal()
|
||||
try:
|
||||
UserService.init_default_admin(db)
|
||||
UserService.init_default_permissions(db)
|
||||
print("✅ 系统初始化完成,默认管理员账户: admin / admin123")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
yield
|
||||
# 关闭时清理
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="企业级IP地址管理系统",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS 配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册 API 路由
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""根路径"""
|
||||
return {
|
||||
"name": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
"docs": "/docs",
|
||||
"redoc": "/redoc"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查"""
|
||||
return {"status": "healthy"}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,120 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, Float
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class AlertType(str, enum.Enum):
|
||||
"""告警类型"""
|
||||
IP_CONFLICT = "ip_conflict" # IP 冲突
|
||||
UNAUTHORIZED_ACCESS = "unauthorized_access" # 未授权接入
|
||||
SUBNET_FULL = "subnet_full" # 网段耗尽
|
||||
SCAN_FAILED = "scan_failed" # 扫描失败
|
||||
DEVICE_OFFLINE = "device_offline" # 设备离线
|
||||
NEW_DEVICE_DETECTED = "new_device_detected" # 新设备发现
|
||||
MAC_CHANGED = "mac_changed" # MAC 地址变化
|
||||
|
||||
|
||||
class AlertSeverity(str, enum.Enum):
|
||||
"""告警级别"""
|
||||
INFO = "info" # 信息
|
||||
WARNING = "warning" # 警告
|
||||
ERROR = "error" # 错误
|
||||
CRITICAL = "critical" # 严重
|
||||
|
||||
|
||||
class AlertStatus(str, enum.Enum):
|
||||
"""告警状态"""
|
||||
ACTIVE = "active" # 活跃
|
||||
ACKNOWLEDGED = "acknowledged" # 已确认
|
||||
RESOLVED = "resolved" # 已解决
|
||||
IGNORED = "ignored" # 已忽略
|
||||
|
||||
|
||||
class Alert(Base):
|
||||
"""告警"""
|
||||
__tablename__ = "alerts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
alert_type = Column(Enum(AlertType), nullable=False, index=True)
|
||||
severity = Column(Enum(AlertSeverity), default=AlertSeverity.WARNING, index=True)
|
||||
status = Column(Enum(AlertStatus), default=AlertStatus.ACTIVE, index=True)
|
||||
|
||||
title = Column(String(255), nullable=False)
|
||||
message = Column(Text)
|
||||
|
||||
# 关联对象
|
||||
network_id = Column(Integer, ForeignKey("networks.id"), nullable=True)
|
||||
ip_address_id = Column(Integer, ForeignKey("ip_addresses.id"), nullable=True)
|
||||
device_id = Column(Integer, ForeignKey("network_devices.id"), nullable=True)
|
||||
ip_address_str = Column(String(50), nullable=True) # 冗余存储便于查询
|
||||
|
||||
# 关联的 IP/MAC 信息
|
||||
mac_address = Column(String(50), nullable=True)
|
||||
conflicting_mac = Column(String(50), nullable=True) # 用于 IP 冲突告警
|
||||
|
||||
# 统计信息
|
||||
usage_percent = Column(Float, nullable=True) # 网段使用率告警时存储
|
||||
|
||||
# 处理信息
|
||||
acknowledged_by = Column(String(100), nullable=True)
|
||||
acknowledged_at = Column(DateTime(timezone=True), nullable=True)
|
||||
resolved_by = Column(String(100), nullable=True)
|
||||
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
resolution_notes = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关系
|
||||
network = relationship("Network")
|
||||
ip_address = relationship("IPAddress")
|
||||
device = relationship("NetworkDevice")
|
||||
|
||||
|
||||
class AlertRule(Base):
|
||||
"""告警规则配置"""
|
||||
__tablename__ = "alert_rules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
alert_type = Column(Enum(AlertType), nullable=False)
|
||||
severity = Column(Enum(AlertSeverity), default=AlertSeverity.WARNING)
|
||||
|
||||
# 规则条件
|
||||
enabled = Column(Boolean, default=True)
|
||||
|
||||
# 阈值配置
|
||||
subnet_usage_threshold = Column(Float, default=90.0) # 网段使用率阈值 %
|
||||
scan_failure_threshold = Column(Integer, default=3) # 扫描失败次数阈值
|
||||
inactivity_threshold_minutes = Column(Integer, default=60) # 设备不活动阈值(分钟)
|
||||
|
||||
# MAC 白名单(用于未授权接入检测)
|
||||
mac_whitelist_enabled = Column(Boolean, default=False)
|
||||
mac_whitelist = Column(Text, nullable=True) # JSON 格式的 MAC 列表
|
||||
|
||||
# 通知配置
|
||||
notify_email = Column(Boolean, default=False)
|
||||
notify_webhook = Column(Boolean, default=False)
|
||||
notification_channels = Column(Text, nullable=True) # JSON 格式的通知渠道
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
|
||||
class WhitelistedMAC(Base):
|
||||
"""MAC 地址白名单"""
|
||||
__tablename__ = "whitelisted_macs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
mac_address = Column(String(50), unique=True, nullable=False, index=True)
|
||||
description = Column(String(255), nullable=True)
|
||||
owner = Column(String(100), nullable=True)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
@@ -0,0 +1,37 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Index
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""审计日志:记录用户的关键操作"""
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 操作人(nullable:登录失败、token 失效等场景可能没有 user_id)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
username = Column(String(50), index=True, comment="冗余存储用户名,便于 user 被删除后仍能查看")
|
||||
|
||||
# 操作内容
|
||||
action = Column(String(50), nullable=False, index=True, comment="操作类型,如:network.create")
|
||||
resource_type = Column(String(50), nullable=True, index=True, comment="资源类型:network / ip / snmp / user / scan")
|
||||
resource_id = Column(String(50), nullable=True, comment="资源 ID(字符串以便兼容多种类型)")
|
||||
resource_name = Column(String(200), nullable=True, comment="资源名称/标识,便于阅读")
|
||||
|
||||
# 请求信息
|
||||
method = Column(String(10), comment="HTTP 方法")
|
||||
path = Column(String(500), comment="请求路径")
|
||||
ip_address = Column(String(50), comment="客户端 IP")
|
||||
user_agent = Column(String(500), comment="User-Agent")
|
||||
|
||||
# 状态
|
||||
status = Column(String(20), default="success", index=True, comment="success / failed")
|
||||
detail = Column(Text, comment="变更详情 / 错误信息 / JSON diff")
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||
|
||||
|
||||
# 复合索引:按时间+用户查、按时间+资源查更高效
|
||||
Index("idx_audit_logs_user_created", AuditLog.user_id, AuditLog.created_at.desc())
|
||||
Index("idx_audit_logs_resource", AuditLog.resource_type, AuditLog.resource_id, AuditLog.created_at.desc())
|
||||
@@ -0,0 +1,97 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Enum, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
"""用户角色枚举"""
|
||||
SUPER_ADMIN = "super_admin" # 超级管理员 - 所有权限
|
||||
ADMIN = "admin" # 管理员 - 除用户管理外的所有权限
|
||||
OPERATOR = "operator" # 操作员 - 可查看和操作,不能配置
|
||||
VIEWER = "viewer" # 只读用户 - 只能查看
|
||||
|
||||
|
||||
class UserStatus(str, enum.Enum):
|
||||
"""用户状态"""
|
||||
ACTIVE = "active" # 正常
|
||||
INACTIVE = "inactive" # 禁用
|
||||
LOCKED = "locked" # 锁定(登录失败过多)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户表"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(100), unique=True, index=True)
|
||||
phone = Column(String(20), index=True)
|
||||
real_name = Column(String(50), comment="真实姓名")
|
||||
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
|
||||
role = Column(Enum(UserRole), default=UserRole.VIEWER, nullable=False)
|
||||
status = Column(Enum(UserStatus), default=UserStatus.ACTIVE, nullable=False)
|
||||
|
||||
# 登录信息
|
||||
last_login_at = Column(DateTime(timezone=True))
|
||||
last_login_ip = Column(String(50))
|
||||
login_failed_count = Column(Integer, default=0)
|
||||
|
||||
# 通知配置
|
||||
email_notification = Column(Boolean, default=True)
|
||||
wechat_notification = Column(Boolean, default=False)
|
||||
dingtalk_notification = Column(Boolean, default=False)
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关系
|
||||
creator = relationship("User", remote_side=[id])
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""权限项"""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(100), unique=True, nullable=False, comment="权限代码,如:network:create")
|
||||
name = Column(String(100), nullable=False, comment="权限名称")
|
||||
description = Column(Text)
|
||||
category = Column(String(50), comment="权限分类:network, ip, scan, snmp, alert, system")
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""角色权限关联表"""
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
role = Column(Enum(UserRole), nullable=False)
|
||||
permission_id = Column(Integer, ForeignKey("permissions.id"), nullable=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
"""刷新令牌"""
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
token = Column(String(500), unique=True, nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
ip_address = Column(String(50))
|
||||
user_agent = Column(String(500))
|
||||
|
||||
revoked = Column(Boolean, default=False)
|
||||
revoked_at = Column(DateTime(timezone=True))
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 关系
|
||||
user = relationship("User")
|
||||
@@ -0,0 +1,119 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, ForeignKey, JSON, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class NetworkGroup(str, enum.Enum):
|
||||
"""网段分组枚举"""
|
||||
PRODUCTION = "production"
|
||||
OFFICE = "office"
|
||||
TEST = "test"
|
||||
MANAGEMENT = "management"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class IPStatus(str, enum.Enum):
|
||||
"""IP状态枚举"""
|
||||
AVAILABLE = "available"
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
RESERVED = "reserved"
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
"""任务状态枚举"""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class TaskType(str, enum.Enum):
|
||||
"""任务类型枚举"""
|
||||
PING = "ping"
|
||||
ARP = "arp"
|
||||
SNMP = "snmp"
|
||||
|
||||
|
||||
class Network(Base):
|
||||
"""网段模型"""
|
||||
__tablename__ = "networks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
cidr = Column(String(50), unique=True, index=True, nullable=False) # 如: 192.168.1.0/24
|
||||
name = Column(String(100), index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
group_name = Column(String(100), index=True, nullable=True) # 分组名称
|
||||
gateway = Column(String(50), nullable=True)
|
||||
vlan_id = Column(Integer, nullable=True)
|
||||
total_ips = Column(Integer, default=0)
|
||||
used_ips = Column(Integer, default=0)
|
||||
reserved_ips = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关系
|
||||
ip_addresses = relationship("IPAddress", back_populates="network", cascade="all, delete-orphan")
|
||||
scan_tasks = relationship("ScanTask", back_populates="network")
|
||||
|
||||
|
||||
class IPAddress(Base):
|
||||
"""IP地址模型"""
|
||||
__tablename__ = "ip_addresses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
network_id = Column(Integer, ForeignKey("networks.id"), nullable=False)
|
||||
ip_address = Column(String(50), unique=True, index=True, nullable=False)
|
||||
mac_address = Column(String(50), nullable=True, index=True)
|
||||
hostname = Column(String(255), nullable=True)
|
||||
status = Column(Enum(IPStatus), default=IPStatus.AVAILABLE)
|
||||
|
||||
# 业务属性
|
||||
owner = Column(String(100), nullable=True)
|
||||
business_type = Column(String(50), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
custom_fields = Column(JSON, default=dict)
|
||||
|
||||
# 扫描信息
|
||||
last_seen = Column(DateTime(timezone=True), nullable=True)
|
||||
first_seen = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 物理位置信息
|
||||
switch_name = Column(String(100), nullable=True)
|
||||
switch_port = Column(String(50), nullable=True)
|
||||
vendor = Column(String(100), nullable=True) # MAC OUI 解析的厂商
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关系
|
||||
network = relationship("Network", back_populates="ip_addresses")
|
||||
|
||||
|
||||
class ScanTask(Base):
|
||||
"""扫描任务模型"""
|
||||
__tablename__ = "scan_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
network_id = Column(Integer, ForeignKey("networks.id"), nullable=True)
|
||||
task_type = Column(Enum(TaskType), nullable=False)
|
||||
status = Column(Enum(TaskStatus), default=TaskStatus.PENDING)
|
||||
progress = Column(Integer, default=0) # 0-100
|
||||
celery_task_id = Column(String(100), nullable=True)
|
||||
|
||||
# 统计信息
|
||||
total_count = Column(Integer, default=0)
|
||||
success_count = Column(Integer, default=0)
|
||||
failed_count = Column(Integer, default=0)
|
||||
|
||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 关系
|
||||
network = relationship("Network", back_populates="scan_tasks")
|
||||
@@ -0,0 +1,190 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class SNMPVersion(str, enum.Enum):
|
||||
"""SNMP 版本"""
|
||||
V2C = "v2c"
|
||||
V3 = "v3"
|
||||
|
||||
|
||||
class SNMPAuthProtocol(str, enum.Enum):
|
||||
"""SNMP V3 认证协议"""
|
||||
MD5 = "md5"
|
||||
SHA = "sha"
|
||||
SHA256 = "sha256"
|
||||
|
||||
|
||||
class SNMPPrivProtocol(str, enum.Enum):
|
||||
"""SNMP V3 加密协议"""
|
||||
DES = "des"
|
||||
AES = "aes"
|
||||
AES256 = "aes256"
|
||||
|
||||
|
||||
class DeviceType(str, enum.Enum):
|
||||
"""设备类型"""
|
||||
ROUTER = "router"
|
||||
CORE_SWITCH = "core_switch"
|
||||
DISTRIBUTION_SWITCH = "distribution_switch"
|
||||
ACCESS_SWITCH = "access_switch"
|
||||
FIREWALL = "firewall"
|
||||
LOAD_BALANCER = "load_balancer"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class SNMPCredential(Base):
|
||||
"""SNMP 凭据"""
|
||||
__tablename__ = "snmp_credentials"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), unique=True, index=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# 版本
|
||||
version = Column(Enum(SNMPVersion), default=SNMPVersion.V2C, nullable=False)
|
||||
|
||||
# V2C 配置
|
||||
community_string = Column(String(255), nullable=True)
|
||||
|
||||
# V3 配置
|
||||
username = Column(String(100), nullable=True)
|
||||
auth_password = Column(String(255), nullable=True)
|
||||
auth_protocol = Column(Enum(SNMPAuthProtocol), nullable=True)
|
||||
priv_password = Column(String(255), nullable=True)
|
||||
priv_protocol = Column(Enum(SNMPPrivProtocol), nullable=True)
|
||||
|
||||
# 超时和重试
|
||||
timeout = Column(Integer, default=3)
|
||||
retries = Column(Integer, default=2)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关系
|
||||
devices = relationship("NetworkDevice", back_populates="snmp_credential")
|
||||
|
||||
|
||||
class NetworkDevice(Base):
|
||||
"""网络设备"""
|
||||
__tablename__ = "network_devices"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), unique=True, index=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# 连接信息
|
||||
ip_address = Column(String(50), index=True, nullable=False)
|
||||
port = Column(Integer, default=161)
|
||||
|
||||
# 设备信息
|
||||
device_type = Column(Enum(DeviceType), default=DeviceType.OTHER)
|
||||
vendor = Column(String(100), nullable=True)
|
||||
model = Column(String(100), nullable=True)
|
||||
firmware_version = Column(String(100), nullable=True)
|
||||
serial_number = Column(String(100), nullable=True)
|
||||
location = Column(String(200), nullable=True)
|
||||
|
||||
# SNMP 凭据
|
||||
snmp_credential_id = Column(Integer, ForeignKey("snmp_credentials.id"), nullable=True)
|
||||
|
||||
# 状态
|
||||
is_active = Column(Boolean, default=True)
|
||||
last_polled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_successful_poll = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 采集配置
|
||||
arp_poll_interval = Column(Integer, default=300) # ARP表采集间隔(秒)
|
||||
mac_poll_interval = Column(Integer, default=300) # MAC表采集间隔(秒)
|
||||
interface_poll_interval = Column(Integer, default=300) # 接口信息采集间隔
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关系
|
||||
snmp_credential = relationship("SNMPCredential", back_populates="devices")
|
||||
arp_entries = relationship("ARPEntry", back_populates="device", cascade="all, delete-orphan")
|
||||
mac_entries = relationship("MACAddressTable", back_populates="device", cascade="all, delete-orphan")
|
||||
interfaces = relationship("DeviceInterface", back_populates="device", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ARPEntry(Base):
|
||||
"""ARP 表条目 (IP -> MAC 映射)"""
|
||||
__tablename__ = "arp_entries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
device_id = Column(Integer, ForeignKey("network_devices.id"), nullable=False)
|
||||
|
||||
ip_address = Column(String(50), index=True, nullable=False)
|
||||
mac_address = Column(String(50), index=True, nullable=False)
|
||||
interface = Column(String(100), nullable=True) # 接口名称/索引
|
||||
vlan_id = Column(Integer, nullable=True)
|
||||
|
||||
# 老化信息
|
||||
age = Column(Integer, nullable=True) # 秒
|
||||
is_static = Column(Boolean, default=False)
|
||||
|
||||
discovered_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
last_seen = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 关系
|
||||
device = relationship("NetworkDevice", back_populates="arp_entries")
|
||||
|
||||
|
||||
class MACAddressTable(Base):
|
||||
"""MAC 地址表 (MAC -> 端口 映射)"""
|
||||
__tablename__ = "mac_address_table"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
device_id = Column(Integer, ForeignKey("network_devices.id"), nullable=False)
|
||||
|
||||
mac_address = Column(String(50), index=True, nullable=False)
|
||||
vlan_id = Column(Integer, nullable=True)
|
||||
interface = Column(String(100), nullable=True) # 接口名称
|
||||
port_number = Column(Integer, nullable=True) # 端口号
|
||||
|
||||
# 类型: learned, static, management, etc.
|
||||
entry_type = Column(String(50), nullable=True)
|
||||
|
||||
discovered_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
last_seen = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 关系
|
||||
device = relationship("NetworkDevice", back_populates="mac_entries")
|
||||
|
||||
|
||||
class DeviceInterface(Base):
|
||||
"""设备接口信息"""
|
||||
__tablename__ = "device_interfaces"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
device_id = Column(Integer, ForeignKey("network_devices.id"), nullable=False)
|
||||
|
||||
if_index = Column(Integer, nullable=False) # ifIndex
|
||||
if_name = Column(String(100), nullable=True) # ifName
|
||||
if_descr = Column(String(255), nullable=True) # ifDescr
|
||||
if_type = Column(String(50), nullable=True) # ifType
|
||||
if_mtu = Column(Integer, nullable=True) # ifMtu
|
||||
if_speed = Column(Integer, nullable=True) # ifSpeed (bps)
|
||||
if_phys_address = Column(String(50), nullable=True) # ifPhysAddress (MAC)
|
||||
if_admin_status = Column(String(50), nullable=True) # up/down/testing
|
||||
if_oper_status = Column(String(50), nullable=True) # up/down/testing
|
||||
|
||||
# IP 地址
|
||||
ip_address = Column(String(50), nullable=True)
|
||||
ip_netmask = Column(String(50), nullable=True)
|
||||
|
||||
# 统计
|
||||
if_in_octets = Column(Integer, nullable=True)
|
||||
if_out_octets = Column(Integer, nullable=True)
|
||||
if_in_errors = Column(Integer, nullable=True)
|
||||
if_out_errors = Column(Integer, nullable=True)
|
||||
|
||||
last_polled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 关系
|
||||
device = relationship("NetworkDevice", back_populates="interfaces")
|
||||
Binary file not shown.
@@ -0,0 +1,173 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
import ipaddress
|
||||
from app.models.network import IPStatus, TaskStatus, TaskType
|
||||
|
||||
|
||||
# ========== 网段相关 Schemas ==========
|
||||
|
||||
class NetworkBase(BaseModel):
|
||||
cidr: str = Field(..., description="网段CIDR,如: 192.168.1.0/24")
|
||||
name: Optional[str] = Field(None, max_length=100, description="网段名称")
|
||||
description: Optional[str] = Field(None, description="描述")
|
||||
group_name: Optional[str] = Field(None, max_length=100, description="分组名称")
|
||||
gateway: Optional[str] = Field(None, max_length=50, description="网关地址")
|
||||
vlan_id: Optional[int] = Field(None, description="VLAN ID")
|
||||
|
||||
@field_validator('cidr')
|
||||
@classmethod
|
||||
def validate_cidr(cls, v):
|
||||
try:
|
||||
network = ipaddress.ip_network(v, strict=False)
|
||||
return str(network)
|
||||
except ValueError:
|
||||
raise ValueError(f"无效的CIDR格式: {v}")
|
||||
|
||||
@field_validator('gateway')
|
||||
@classmethod
|
||||
def validate_gateway(cls, v):
|
||||
if v:
|
||||
try:
|
||||
ipaddress.ip_address(v)
|
||||
except ValueError:
|
||||
raise ValueError(f"无效的IP地址: {v}")
|
||||
return v
|
||||
|
||||
|
||||
class NetworkCreate(NetworkBase):
|
||||
pass
|
||||
|
||||
|
||||
class NetworkUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
group_name: Optional[str] = None
|
||||
gateway: Optional[str] = None
|
||||
vlan_id: Optional[int] = None
|
||||
|
||||
|
||||
class Network(NetworkBase):
|
||||
id: int
|
||||
total_ips: int
|
||||
used_ips: int
|
||||
reserved_ips: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NetworkStats(BaseModel):
|
||||
"""网段统计信息"""
|
||||
id: int
|
||||
cidr: str
|
||||
name: Optional[str]
|
||||
total_ips: int
|
||||
used_ips: int
|
||||
reserved_ips: int
|
||||
available_ips: int
|
||||
usage_percent: float
|
||||
group_name: Optional[str]
|
||||
vlan_id: Optional[int]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NetworkListResponse(BaseModel):
|
||||
total: int
|
||||
items: List[Network]
|
||||
|
||||
|
||||
# ========== IP地址相关 Schemas ==========
|
||||
|
||||
class IPAddressBase(BaseModel):
|
||||
ip_address: str
|
||||
mac_address: Optional[str] = None
|
||||
hostname: Optional[str] = None
|
||||
owner: Optional[str] = None
|
||||
business_type: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
custom_fields: Dict[str, Any] = Field(default_factory=dict)
|
||||
switch_name: Optional[str] = None
|
||||
switch_port: Optional[str] = None
|
||||
vendor: Optional[str] = None
|
||||
|
||||
|
||||
class IPAddressCreate(IPAddressBase):
|
||||
network_id: int
|
||||
status: IPStatus = IPStatus.AVAILABLE
|
||||
|
||||
|
||||
class IPAddressUpdate(BaseModel):
|
||||
mac_address: Optional[str] = None
|
||||
hostname: Optional[str] = None
|
||||
status: Optional[IPStatus] = None
|
||||
owner: Optional[str] = None
|
||||
business_type: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
custom_fields: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class IPAddress(IPAddressBase):
|
||||
id: int
|
||||
network_id: int
|
||||
status: IPStatus
|
||||
last_seen: Optional[datetime]
|
||||
first_seen: datetime
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class IPAddressListResponse(BaseModel):
|
||||
total: int
|
||||
items: List[IPAddress]
|
||||
|
||||
|
||||
# ========== 扫描任务相关 Schemas ==========
|
||||
|
||||
class ScanTaskBase(BaseModel):
|
||||
task_type: TaskType
|
||||
network_id: Optional[int] = None
|
||||
|
||||
|
||||
class ScanTaskCreate(ScanTaskBase):
|
||||
pass
|
||||
|
||||
|
||||
class ScanTask(BaseModel):
|
||||
id: int
|
||||
network_id: Optional[int]
|
||||
task_type: TaskType
|
||||
status: TaskStatus
|
||||
progress: int
|
||||
celery_task_id: Optional[str]
|
||||
total_count: int
|
||||
success_count: int
|
||||
failed_count: int
|
||||
started_at: Optional[datetime]
|
||||
completed_at: Optional[datetime]
|
||||
error_message: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ScanTaskListResponse(BaseModel):
|
||||
total: int
|
||||
items: List[ScanTask]
|
||||
|
||||
|
||||
class ScanResult(BaseModel):
|
||||
"""单次扫描结果"""
|
||||
ip_address: str
|
||||
status: str
|
||||
mac_address: Optional[str] = None
|
||||
hostname: Optional[str] = None
|
||||
response_time: Optional[float] = None
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,496 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import json
|
||||
|
||||
from app.models.alert import (
|
||||
Alert, AlertRule, WhitelistedMAC,
|
||||
AlertType, AlertSeverity, AlertStatus
|
||||
)
|
||||
from app.models.network import Network, IPAddress
|
||||
from app.models.snmp import NetworkDevice, ARPEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlertService:
|
||||
"""告警检测与管理服务"""
|
||||
|
||||
@staticmethod
|
||||
def create_alert(db: Session,
|
||||
alert_type: AlertType,
|
||||
title: str,
|
||||
message: str = "",
|
||||
severity: AlertSeverity = AlertSeverity.WARNING,
|
||||
network_id: Optional[int] = None,
|
||||
ip_address_id: Optional[int] = None,
|
||||
device_id: Optional[int] = None,
|
||||
ip_address_str: Optional[str] = None,
|
||||
mac_address: Optional[str] = None,
|
||||
conflicting_mac: Optional[str] = None,
|
||||
usage_percent: Optional[float] = None) -> Alert:
|
||||
"""创建告警"""
|
||||
# 检查是否已有相同的活跃告警,避免重复告警
|
||||
existing = db.query(Alert).filter(
|
||||
Alert.alert_type == alert_type,
|
||||
Alert.status == AlertStatus.ACTIVE
|
||||
)
|
||||
|
||||
if ip_address_str:
|
||||
existing = existing.filter(Alert.ip_address_str == ip_address_str)
|
||||
if network_id:
|
||||
existing = existing.filter(Alert.network_id == network_id)
|
||||
if device_id:
|
||||
existing = existing.filter(Alert.device_id == device_id)
|
||||
|
||||
existing = existing.first()
|
||||
|
||||
if existing:
|
||||
logger.debug(f"已有相同的活跃告警: {alert_type} - {ip_address_str or network_id or device_id}")
|
||||
return existing
|
||||
|
||||
alert = Alert(
|
||||
alert_type=alert_type,
|
||||
title=title,
|
||||
message=message,
|
||||
severity=severity,
|
||||
network_id=network_id,
|
||||
ip_address_id=ip_address_id,
|
||||
device_id=device_id,
|
||||
ip_address_str=ip_address_str,
|
||||
mac_address=mac_address,
|
||||
conflicting_mac=conflicting_mac,
|
||||
usage_percent=usage_percent
|
||||
)
|
||||
|
||||
db.add(alert)
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
logger.info(f"创建告警: {alert_type} - {title}")
|
||||
return alert
|
||||
|
||||
@staticmethod
|
||||
def detect_ip_conflicts(db: Session) -> List[Dict[str, Any]]:
|
||||
"""检测 IP 冲突"""
|
||||
conflicts = []
|
||||
|
||||
# 检查 ARP 表中同一个 IP 对应多个不同 MAC 的情况
|
||||
subquery = db.query(
|
||||
ARPEntry.ip_address
|
||||
).group_by(ARPEntry.ip_address).having(
|
||||
db.func.count(db.func.distinct(ARPEntry.mac_address)) > 1
|
||||
).subquery()
|
||||
|
||||
conflict_entries = db.query(ARPEntry).filter(
|
||||
ARPEntry.ip_address.in_(subquery)
|
||||
).order_by(ARPEntry.ip_address).all()
|
||||
|
||||
# 按 IP 分组
|
||||
ip_macs = {}
|
||||
for entry in conflict_entries:
|
||||
if entry.ip_address not in ip_macs:
|
||||
ip_macs[entry.ip_address] = set()
|
||||
ip_macs[entry.ip_address].add(entry.mac_address)
|
||||
|
||||
# 为每个冲突创建告警
|
||||
for ip, macs in ip_macs.items():
|
||||
if len(macs) > 1:
|
||||
mac_list = list(macs)
|
||||
conflict_info = {
|
||||
'ip_address': ip,
|
||||
'mac_addresses': mac_list
|
||||
}
|
||||
conflicts.append(conflict_info)
|
||||
|
||||
# 创建告警
|
||||
AlertService.create_alert(
|
||||
db=db,
|
||||
alert_type=AlertType.IP_CONFLICT,
|
||||
title=f"IP 冲突检测: {ip}",
|
||||
message=f"IP 地址 {ip} 检测到多个 MAC 地址: {', '.join(mac_list)}",
|
||||
severity=AlertSeverity.ERROR,
|
||||
ip_address_str=ip,
|
||||
mac_address=mac_list[0],
|
||||
conflicting_mac=mac_list[1] if len(mac_list) > 1 else None
|
||||
)
|
||||
|
||||
return conflicts
|
||||
|
||||
@staticmethod
|
||||
def detect_unauthorized_access(db: Session) -> List[Dict[str, Any]]:
|
||||
"""检测未授权接入(新 MAC 地址不在白名单中)"""
|
||||
unauthorized = []
|
||||
|
||||
# 获取白名单 MAC
|
||||
whitelist = set()
|
||||
whitelist_entries = db.query(WhitelistedMAC).filter(
|
||||
WhitelistedMAC.is_active == True
|
||||
).all()
|
||||
for entry in whitelist_entries:
|
||||
whitelist.add(entry.mac_address.upper())
|
||||
|
||||
# 获取所有 ARP 表中的 MAC 地址
|
||||
arp_entries = db.query(ARPEntry).all()
|
||||
|
||||
for entry in arp_entries:
|
||||
if not entry.mac_address:
|
||||
continue
|
||||
|
||||
mac = entry.mac_address.upper()
|
||||
if mac and mac not in whitelist:
|
||||
# 检查是否是最近发现的(1小时内)
|
||||
is_recent = entry.discovered_at >= datetime.utcnow() - timedelta(hours=1)
|
||||
|
||||
if is_recent:
|
||||
unauth_info = {
|
||||
'ip_address': entry.ip_address,
|
||||
'mac_address': mac,
|
||||
'discovered_at': entry.discovered_at
|
||||
}
|
||||
unauthorized.append(unauth_info)
|
||||
|
||||
# 创建告警
|
||||
AlertService.create_alert(
|
||||
db=db,
|
||||
alert_type=AlertType.UNAUTHORIZED_ACCESS,
|
||||
title=f"未授权接入: {mac}",
|
||||
message=f"检测到未授权设备接入: IP {entry.ip_address}, MAC {mac}",
|
||||
severity=AlertSeverity.WARNING,
|
||||
ip_address_str=entry.ip_address,
|
||||
mac_address=mac
|
||||
)
|
||||
|
||||
return unauthorized
|
||||
|
||||
@staticmethod
|
||||
def detect_subnet_exhaustion(db: Session) -> List[Dict[str, Any]]:
|
||||
"""检测网段耗尽"""
|
||||
exhausted = []
|
||||
|
||||
# 获取所有网段的统计信息
|
||||
networks = db.query(Network).all()
|
||||
|
||||
for network in networks:
|
||||
if network.total_ips == 0:
|
||||
continue
|
||||
|
||||
# 计算使用中的 IP 数量(在线或已分配)
|
||||
used_count = db.query(IPAddress).filter(
|
||||
IPAddress.network_id == network.id
|
||||
).filter(
|
||||
(IPAddress.mac_address.isnot(None)) |
|
||||
(IPAddress.hostname.isnot(None))
|
||||
).count()
|
||||
|
||||
usage_percent = (used_count / network.total_ips) * 100
|
||||
|
||||
# 默认阈值 90%
|
||||
threshold = 90.0
|
||||
|
||||
if usage_percent >= threshold:
|
||||
exhaustion_info = {
|
||||
'network_id': network.id,
|
||||
'cidr': network.cidr,
|
||||
'name': network.name,
|
||||
'total_ips': network.total_ips,
|
||||
'used_ips': used_count,
|
||||
'usage_percent': round(usage_percent, 2)
|
||||
}
|
||||
exhausted.append(exhaustion_info)
|
||||
|
||||
# 创建告警
|
||||
AlertService.create_alert(
|
||||
db=db,
|
||||
alert_type=AlertType.SUBNET_FULL,
|
||||
title=f"网段使用率告警: {network.cidr}",
|
||||
message=f"网段 {network.cidr} ({network.name}) 使用率达到 {round(usage_percent, 2)}%,总IP {network.total_ips},已使用 {used_count}",
|
||||
severity=AlertSeverity.WARNING if usage_percent < 95 else AlertSeverity.CRITICAL,
|
||||
network_id=network.id,
|
||||
usage_percent=usage_percent
|
||||
)
|
||||
|
||||
return exhausted
|
||||
|
||||
@staticmethod
|
||||
def detect_new_devices(db: Session) -> List[Dict[str, Any]]:
|
||||
"""检测新发现的设备"""
|
||||
new_devices = []
|
||||
|
||||
# 查找最近 1 小时内发现的新 MAC 地址
|
||||
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
||||
|
||||
recent_entries = db.query(ARPEntry).filter(
|
||||
ARPEntry.discovered_at >= one_hour_ago
|
||||
).all()
|
||||
|
||||
for entry in recent_entries:
|
||||
# 检查此 MAC 是否是新出现的
|
||||
older_entries = db.query(ARPEntry).filter(
|
||||
ARPEntry.mac_address == entry.mac_address,
|
||||
ARPEntry.discovered_at < one_hour_ago
|
||||
).count()
|
||||
|
||||
if older_entries == 0 and entry.mac_address:
|
||||
device_info = {
|
||||
'ip_address': entry.ip_address,
|
||||
'mac_address': entry.mac_address,
|
||||
'discovered_at': entry.discovered_at
|
||||
}
|
||||
new_devices.append(device_info)
|
||||
|
||||
# 创建告警
|
||||
AlertService.create_alert(
|
||||
db=db,
|
||||
alert_type=AlertType.NEW_DEVICE_DETECTED,
|
||||
title=f"新设备发现: {entry.ip_address}",
|
||||
message=f"检测到新设备: IP {entry.ip_address}, MAC {entry.mac_address}",
|
||||
severity=AlertSeverity.INFO,
|
||||
ip_address_str=entry.ip_address,
|
||||
mac_address=entry.mac_address
|
||||
)
|
||||
|
||||
return new_devices
|
||||
|
||||
@staticmethod
|
||||
def detect_device_offline(db: Session) -> List[Dict[str, Any]]:
|
||||
"""检测设备离线"""
|
||||
offline_devices = []
|
||||
|
||||
# 检查网络设备是否长时间没有成功轮询
|
||||
threshold_time = datetime.utcnow() - timedelta(hours=2)
|
||||
|
||||
offline_network_devices = db.query(NetworkDevice).filter(
|
||||
NetworkDevice.is_active == True,
|
||||
(NetworkDevice.last_successful_poll < threshold_time) |
|
||||
(NetworkDevice.last_successful_poll == None)
|
||||
).all()
|
||||
|
||||
for device in offline_network_devices:
|
||||
offline_info = {
|
||||
'device_id': device.id,
|
||||
'device_name': device.name,
|
||||
'ip_address': device.ip_address,
|
||||
'last_polled_at': device.last_polled_at,
|
||||
'last_successful_poll': device.last_successful_poll
|
||||
}
|
||||
offline_devices.append(offline_info)
|
||||
|
||||
# 创建告警
|
||||
AlertService.create_alert(
|
||||
db=db,
|
||||
alert_type=AlertType.DEVICE_OFFLINE,
|
||||
title=f"设备离线: {device.name}",
|
||||
message=f"网络设备 {device.name} ({device.ip_address}) 已超过 2 小时未成功轮询",
|
||||
severity=AlertSeverity.ERROR,
|
||||
device_id=device.id
|
||||
)
|
||||
|
||||
return offline_devices
|
||||
|
||||
@staticmethod
|
||||
def run_all_detections(db: Session) -> Dict[str, Any]:
|
||||
"""运行所有检测"""
|
||||
results = {}
|
||||
|
||||
logger.info("开始运行告警检测...")
|
||||
|
||||
# 检测 IP 冲突
|
||||
conflicts = AlertService.detect_ip_conflicts(db)
|
||||
results['ip_conflicts'] = {
|
||||
'count': len(conflicts),
|
||||
'items': conflicts
|
||||
}
|
||||
|
||||
# 检测未授权接入
|
||||
unauthorized = AlertService.detect_unauthorized_access(db)
|
||||
results['unauthorized_access'] = {
|
||||
'count': len(unauthorized),
|
||||
'items': unauthorized
|
||||
}
|
||||
|
||||
# 检测网段耗尽
|
||||
subnet_exhaustion = AlertService.detect_subnet_exhaustion(db)
|
||||
results['subnet_exhaustion'] = {
|
||||
'count': len(subnet_exhaustion),
|
||||
'items': subnet_exhaustion
|
||||
}
|
||||
|
||||
# 检测新设备
|
||||
new_devices = AlertService.detect_new_devices(db)
|
||||
results['new_devices'] = {
|
||||
'count': len(new_devices),
|
||||
'items': new_devices
|
||||
}
|
||||
|
||||
# 检测设备离线
|
||||
offline_devices = AlertService.detect_device_offline(db)
|
||||
results['offline_devices'] = {
|
||||
'count': len(offline_devices),
|
||||
'items': offline_devices
|
||||
}
|
||||
|
||||
total_alerts = sum(v['count'] for v in results.values())
|
||||
logger.info(f"告警检测完成,共发现 {total_alerts} 个问题")
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def get_alerts(db: Session,
|
||||
status: Optional[AlertStatus] = None,
|
||||
severity: Optional[AlertSeverity] = None,
|
||||
alert_type: Optional[AlertType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100) -> tuple[int, List[Alert]]:
|
||||
"""获取告警列表"""
|
||||
query = db.query(Alert)
|
||||
|
||||
if status:
|
||||
query = query.filter(Alert.status == status)
|
||||
if severity:
|
||||
query = query.filter(Alert.severity == severity)
|
||||
if alert_type:
|
||||
query = query.filter(Alert.alert_type == alert_type)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(Alert.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def acknowledge_alert(db: Session, alert_id: int, acknowledged_by: str = "system") -> Optional[Alert]:
|
||||
"""确认告警"""
|
||||
alert = db.query(Alert).filter(Alert.id == alert_id).first()
|
||||
if not alert:
|
||||
return None
|
||||
|
||||
alert.status = AlertStatus.ACKNOWLEDGED
|
||||
alert.acknowledged_by = acknowledged_by
|
||||
alert.acknowledged_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
return alert
|
||||
|
||||
@staticmethod
|
||||
def resolve_alert(db: Session, alert_id: int, resolved_by: str = "system", notes: str = "") -> Optional[Alert]:
|
||||
"""解决告警"""
|
||||
alert = db.query(Alert).filter(Alert.id == alert_id).first()
|
||||
if not alert:
|
||||
return None
|
||||
|
||||
alert.status = AlertStatus.RESOLVED
|
||||
alert.resolved_by = resolved_by
|
||||
alert.resolved_at = datetime.utcnow()
|
||||
alert.resolution_notes = notes
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
return alert
|
||||
|
||||
@staticmethod
|
||||
def ignore_alert(db: Session, alert_id: int) -> Optional[Alert]:
|
||||
"""忽略告警"""
|
||||
alert = db.query(Alert).filter(Alert.id == alert_id).first()
|
||||
if not alert:
|
||||
return None
|
||||
|
||||
alert.status = AlertStatus.IGNORED
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
return alert
|
||||
|
||||
@staticmethod
|
||||
def get_statistics(db: Session) -> Dict[str, Any]:
|
||||
"""获取告警统计"""
|
||||
active_count = db.query(Alert).filter(Alert.status == AlertStatus.ACTIVE).count()
|
||||
acknowledged_count = db.query(Alert).filter(Alert.status == AlertStatus.ACKNOWLEDGED).count()
|
||||
resolved_count = db.query(Alert).filter(Alert.status == AlertStatus.RESOLVED).count()
|
||||
|
||||
# 按严重级别统计
|
||||
critical_count = db.query(Alert).filter(
|
||||
Alert.status == AlertStatus.ACTIVE,
|
||||
Alert.severity == AlertSeverity.CRITICAL
|
||||
).count()
|
||||
error_count = db.query(Alert).filter(
|
||||
Alert.status == AlertStatus.ACTIVE,
|
||||
Alert.severity == AlertSeverity.ERROR
|
||||
).count()
|
||||
warning_count = db.query(Alert).filter(
|
||||
Alert.status == AlertStatus.ACTIVE,
|
||||
Alert.severity == AlertSeverity.WARNING
|
||||
).count()
|
||||
info_count = db.query(Alert).filter(
|
||||
Alert.status == AlertStatus.ACTIVE,
|
||||
Alert.severity == AlertSeverity.INFO
|
||||
).count()
|
||||
|
||||
# 按类型统计
|
||||
type_stats = {}
|
||||
for alert_type in AlertType:
|
||||
count = db.query(Alert).filter(
|
||||
Alert.status == AlertStatus.ACTIVE,
|
||||
Alert.alert_type == alert_type
|
||||
).count()
|
||||
type_stats[alert_type.value] = count
|
||||
|
||||
return {
|
||||
'by_status': {
|
||||
'active': active_count,
|
||||
'acknowledged': acknowledged_count,
|
||||
'resolved': resolved_count
|
||||
},
|
||||
'by_severity': {
|
||||
'critical': critical_count,
|
||||
'error': error_count,
|
||||
'warning': warning_count,
|
||||
'info': info_count
|
||||
},
|
||||
'by_type': type_stats
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def add_mac_to_whitelist(db: Session, mac_address: str, description: str = "", owner: str = "") -> WhitelistedMAC:
|
||||
"""添加 MAC 地址到白名单"""
|
||||
existing = db.query(WhitelistedMAC).filter(WhitelistedMAC.mac_address == mac_address.upper()).first()
|
||||
if existing:
|
||||
existing.is_active = True
|
||||
if description:
|
||||
existing.description = description
|
||||
if owner:
|
||||
existing.owner = owner
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
whitelisted_mac = WhitelistedMAC(
|
||||
mac_address=mac_address.upper(),
|
||||
description=description,
|
||||
owner=owner
|
||||
)
|
||||
db.add(whitelisted_mac)
|
||||
db.commit()
|
||||
db.refresh(whitelisted_mac)
|
||||
|
||||
return whitelisted_mac
|
||||
|
||||
@staticmethod
|
||||
def get_mac_whitelist(db: Session, skip: int = 0, limit: int = 100) -> tuple[int, List[WhitelistedMAC]]:
|
||||
"""获取 MAC 白名单"""
|
||||
query = db.query(WhitelistedMAC).filter(WhitelistedMAC.is_active == True)
|
||||
total = query.count()
|
||||
items = query.order_by(WhitelistedMAC.id.desc()).offset(skip).limit(limit).all()
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def remove_mac_from_whitelist(db: Session, mac_id: int) -> bool:
|
||||
"""从白名单移除 MAC 地址"""
|
||||
mac = db.query(WhitelistedMAC).filter(WhitelistedMAC.id == mac_id).first()
|
||||
if not mac:
|
||||
return False
|
||||
|
||||
mac.is_active = False
|
||||
db.commit()
|
||||
return True
|
||||
@@ -0,0 +1,155 @@
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.auth import User
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""审计日志服务"""
|
||||
|
||||
@staticmethod
|
||||
def record(
|
||||
db: Session,
|
||||
*,
|
||||
action: str,
|
||||
user: Optional[User] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
resource_name: Optional[str] = None,
|
||||
method: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
status: str = "success",
|
||||
detail: Optional[Any] = None,
|
||||
) -> AuditLog:
|
||||
"""
|
||||
写入一条审计日志。任意字段缺失都安全降级(不抛异常),
|
||||
避免审计日志写入失败影响主业务流程。
|
||||
"""
|
||||
try:
|
||||
log = AuditLog(
|
||||
user_id=user.id if user else None,
|
||||
username=user.username if user else None,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=str(resource_id) if resource_id is not None else None,
|
||||
resource_name=resource_name,
|
||||
method=method,
|
||||
path=path,
|
||||
ip_address=ip_address,
|
||||
user_agent=(user_agent or "")[:500],
|
||||
status=status,
|
||||
detail=_serialize_detail(detail),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
return log
|
||||
except Exception as e:
|
||||
# 写审计日志失败不能影响主业务,仅回滚
|
||||
db.rollback()
|
||||
# 用 print 而非 logger,避免循环依赖(logger 可能未初始化)
|
||||
print(f"[audit] failed to write log action={action}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_logs(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> Tuple[int, List[AuditLog]]:
|
||||
query = db.query(AuditLog)
|
||||
|
||||
if user_id is not None:
|
||||
query = query.filter(AuditLog.user_id == user_id)
|
||||
if username:
|
||||
query = query.filter(AuditLog.username.like(f"%{username}%"))
|
||||
if action:
|
||||
query = query.filter(AuditLog.action.like(f"%{action}%"))
|
||||
if resource_type:
|
||||
query = query.filter(AuditLog.resource_type == resource_type)
|
||||
if status:
|
||||
query = query.filter(AuditLog.status == status)
|
||||
if start_time:
|
||||
query = query.filter(AuditLog.created_at >= start_time)
|
||||
if end_time:
|
||||
query = query.filter(AuditLog.created_at <= end_time)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(desc(AuditLog.created_at)).offset(skip).limit(limit).all()
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def get_stats(db: Session) -> Dict[str, Any]:
|
||||
"""首页/汇总用:最近 24h 操作数 + 按 action 分组"""
|
||||
from datetime import timedelta
|
||||
cutoff = datetime.utcnow() - timedelta(hours=24)
|
||||
recent = db.query(AuditLog).filter(AuditLog.created_at >= cutoff).count()
|
||||
by_action = db.query(AuditLog.action, db.query(AuditLog).filter(AuditLog.created_at >= cutoff).subquery()) # 占位避免循环
|
||||
# 简化:用 group by
|
||||
from sqlalchemy import func
|
||||
rows = db.query(AuditLog.action, func.count(AuditLog.id)).filter(
|
||||
AuditLog.created_at >= cutoff
|
||||
).group_by(AuditLog.action).all()
|
||||
return {
|
||||
"recent_24h": recent,
|
||||
"by_action_24h": {action: count for action, count in rows},
|
||||
}
|
||||
|
||||
|
||||
def _serialize_detail(detail: Any) -> Optional[str]:
|
||||
"""把 dict/list 等结构化数据序列化成字符串,便于存储和展示"""
|
||||
if detail is None:
|
||||
return None
|
||||
if isinstance(detail, str):
|
||||
return detail[:4000]
|
||||
try:
|
||||
return json.dumps(detail, ensure_ascii=False, default=str)[:4000]
|
||||
except (TypeError, ValueError):
|
||||
return str(detail)[:4000]
|
||||
|
||||
|
||||
# 常用 action 字符串集中管理(避免散落各处的魔法字符串)
|
||||
class AuditAction:
|
||||
# 网络
|
||||
NETWORK_CREATE = "network.create"
|
||||
NETWORK_UPDATE = "network.update"
|
||||
NETWORK_DELETE = "network.delete"
|
||||
# IP
|
||||
IP_UPDATE = "ip.update"
|
||||
IP_RESERVE = "ip.reserve"
|
||||
IP_UNRESERVE = "ip.unreserve"
|
||||
# 扫描
|
||||
SCAN_TRIGGER = "scan.trigger"
|
||||
# SNMP
|
||||
SNMP_CRED_CREATE = "snmp.credential.create"
|
||||
SNMP_CRED_UPDATE = "snmp.credential.update"
|
||||
SNMP_CRED_DELETE = "snmp.credential.delete"
|
||||
SNMP_DEVICE_CREATE = "snmp.device.create"
|
||||
SNMP_DEVICE_UPDATE = "snmp.device.update"
|
||||
SNMP_DEVICE_DELETE = "snmp.device.delete"
|
||||
# 告警
|
||||
ALERT_ACK = "alert.acknowledge"
|
||||
ALERT_RESOLVE = "alert.resolve"
|
||||
# 用户管理
|
||||
USER_LOGIN = "user.login"
|
||||
USER_LOGIN_FAILED = "user.login.failed"
|
||||
USER_LOGOUT = "user.logout"
|
||||
USER_CREATE = "user.create"
|
||||
USER_UPDATE_STATUS = "user.update_status"
|
||||
USER_UNLOCK = "user.unlock"
|
||||
USER_RESET_PASSWORD = "user.reset_password"
|
||||
USER_DELETE = "user.delete"
|
||||
USER_CHANGE_PASSWORD = "user.change_password"
|
||||
@@ -0,0 +1,381 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import subprocess
|
||||
import socket
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
from app.models.network import ScanTask, Network, IPAddress
|
||||
from app.models.network import TaskStatus, TaskType, IPStatus
|
||||
from app.core.config import settings
|
||||
|
||||
logger = __import__('logging').getLogger(__name__)
|
||||
|
||||
|
||||
class EnhancedScanService:
|
||||
"""增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别"""
|
||||
|
||||
# MAC OUI 厂商映射(常用厂商)
|
||||
OUI_MAP = {
|
||||
'00:1C:14': 'VMware',
|
||||
'00:0C:29': 'VMware',
|
||||
'00:50:56': 'VMware',
|
||||
'00:15:5D': 'Microsoft',
|
||||
'08:00:27': 'VirtualBox',
|
||||
'52:54:00': 'QEMU/KVM',
|
||||
'00:1A:2A': 'Dell',
|
||||
'00:21:9B': 'Dell',
|
||||
'00:26:B9': 'Dell',
|
||||
'00:1B:21': 'HP',
|
||||
'00:23:7D': 'HP',
|
||||
'00:1E:0B': 'Cisco',
|
||||
'00:21:55': 'Cisco',
|
||||
'00:23:04': 'Cisco',
|
||||
'00:24:14': 'Cisco',
|
||||
'00:1F:CA': 'Cisco',
|
||||
'00:19:BB': 'Huawei',
|
||||
'00:25:9E': 'Huawei',
|
||||
'00:E0:FC': 'Huawei',
|
||||
'AC:85:3D': 'Huawei',
|
||||
'28:6E:D4': 'Huawei',
|
||||
'00:16:6F': 'H3C',
|
||||
'00:23:89': 'H3C',
|
||||
'00:0F:E2': 'Intel',
|
||||
'00:1B:77': 'Intel',
|
||||
'00:21:6A': 'Intel',
|
||||
'1C:6F:65': 'Intel',
|
||||
'00:19:D2': 'Realtek',
|
||||
'00:24:1D': 'Realtek',
|
||||
'00:E0:4C': 'Realtek',
|
||||
'F4:6D:04': 'Apple',
|
||||
'E0:F8:47': 'Apple',
|
||||
'C8:BC:C8': 'Apple',
|
||||
'04:15:52': 'Apple',
|
||||
'28:CF:E9': 'Apple',
|
||||
'04:7D:7B': 'Xiaomi',
|
||||
'18:59:36': 'Xiaomi',
|
||||
'34:CE:00': 'Xiaomi',
|
||||
'64:09:80': 'TP-Link',
|
||||
'E8:94:F6': 'TP-Link',
|
||||
'50:FA:84': 'TP-Link',
|
||||
'00:24:01': 'TP-Link',
|
||||
'30:FC:68': 'NETGEAR',
|
||||
'00:09:5B': 'NETGEAR',
|
||||
'00:FF:FF': 'Broadcast',
|
||||
'FF:FF:FF': 'Broadcast',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_mac_vendor(mac_address: str) -> Optional[str]:
|
||||
"""根据MAC地址OUI识别厂商"""
|
||||
if not mac_address:
|
||||
return None
|
||||
|
||||
# 标准化MAC格式为 AA:BB:CC:DD:EE:FF
|
||||
mac = mac_address.upper().replace('-', ':')
|
||||
if len(mac) < 8:
|
||||
return None
|
||||
|
||||
# 提取前3字节 OUI
|
||||
oui = mac[:8]
|
||||
|
||||
# 精确匹配
|
||||
if oui in EnhancedScanService.OUI_MAP:
|
||||
return EnhancedScanService.OUI_MAP[oui]
|
||||
|
||||
# 尝试模糊匹配(前2字节)
|
||||
oui_short = mac[:5]
|
||||
for key, vendor in EnhancedScanService.OUI_MAP.items():
|
||||
if key.startswith(oui_short):
|
||||
return vendor
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def reverse_dns_lookup(ip_address: str, timeout: int = 2) -> Optional[str]:
|
||||
"""反向DNS解析获取主机名"""
|
||||
try:
|
||||
socket.setdefaulttimeout(timeout)
|
||||
hostname, _, _ = socket.gethostbyaddr(ip_address)
|
||||
return hostname
|
||||
except (socket.herror, socket.timeout, socket.gaierror):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def arp_scan_single(ip_address: str, timeout: int = 2) -> Optional[str]:
|
||||
"""
|
||||
对单个IP执行ARP扫描获取MAC地址
|
||||
返回: MAC地址 或 None
|
||||
"""
|
||||
# 方法1: 使用 arp-scan 命令(比 scapy 更可靠,不依赖 raw socket)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['arp-scan', '-I', 'auto', ip_address, '-q'],
|
||||
capture_output=True, text=True, timeout=timeout + 2
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# arp-scan 输出格式: IP\tMAC\tVendor
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split('\t')
|
||||
if len(parts) >= 2:
|
||||
mac = parts[1].strip()
|
||||
if mac and mac != '<incomplete>':
|
||||
return mac.upper()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 方法2: 尝试读取系统 ARP 缓存
|
||||
return EnhancedScanService._get_arp_from_cache(ip_address)
|
||||
|
||||
@staticmethod
|
||||
def _get_arp_from_cache(ip_address: str) -> Optional[str]:
|
||||
"""从系统ARP缓存读取MAC地址"""
|
||||
try:
|
||||
# 读取 /proc/net/arp (Linux)
|
||||
with open('/proc/net/arp', 'r') as f:
|
||||
for line in f.readlines()[1:]: # 跳过表头
|
||||
parts = line.split()
|
||||
if len(parts) >= 4 and parts[0] == ip_address:
|
||||
mac = parts[3].upper()
|
||||
if mac != '00:00:00:00:00:00':
|
||||
return mac
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 尝试 arp -a 命令
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['arp', '-a', ip_address],
|
||||
capture_output=True, text=True,
|
||||
timeout=2
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# 匹配 MAC 地址格式
|
||||
mac_pattern = r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})'
|
||||
match = re.search(mac_pattern, result.stdout)
|
||||
if match:
|
||||
return match.group().upper()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def comprehensive_scan(ip_address: str,
|
||||
enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True,
|
||||
timeout: int = 2) -> Dict[str, Any]:
|
||||
"""
|
||||
综合扫描单个IP
|
||||
返回: {
|
||||
'ip_address': str,
|
||||
'status': 'online'|'offline',
|
||||
'mac_address': str|None,
|
||||
'hostname': str|None,
|
||||
'vendor': str|None,
|
||||
'response_time': float|None
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
'ip_address': ip_address,
|
||||
'status': 'offline',
|
||||
'mac_address': None,
|
||||
'hostname': None,
|
||||
'vendor': None,
|
||||
'success': False
|
||||
}
|
||||
|
||||
# 1. Ping 扫描
|
||||
if enable_ping:
|
||||
ping_result = EnhancedScanService.ping_single_ip(ip_address, timeout)
|
||||
result['status'] = ping_result.get('status', 'offline')
|
||||
result['success'] = ping_result.get('success', False)
|
||||
result['response_time'] = ping_result.get('response_time')
|
||||
|
||||
# 2. ARP 扫描 (即使ping不通也尝试,有些设备禁ping)
|
||||
if enable_arp:
|
||||
mac = EnhancedScanService.arp_scan_single(ip_address, timeout)
|
||||
if mac:
|
||||
result['mac_address'] = mac
|
||||
result['vendor'] = EnhancedScanService.get_mac_vendor(mac)
|
||||
# 如果获取到MAC,说明设备实际上在线
|
||||
result['status'] = 'online'
|
||||
result['success'] = True
|
||||
|
||||
# 3. 反向 DNS 解析
|
||||
if enable_dns and result.get('success'):
|
||||
hostname = EnhancedScanService.reverse_dns_lookup(ip_address, timeout)
|
||||
if hostname:
|
||||
result['hostname'] = hostname
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def ping_single_ip(ip_address: str, timeout: int = 2) -> Dict[str, Any]:
|
||||
"""Ping 单个IP"""
|
||||
start_time = datetime.now()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ping', '-c', '1', '-W', str(timeout), ip_address],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
is_alive = result.returncode == 0
|
||||
response_time = (datetime.now() - start_time).total_seconds() * 1000
|
||||
|
||||
return {
|
||||
'ip_address': ip_address,
|
||||
'status': 'online' if is_alive else 'offline',
|
||||
'response_time': round(response_time, 2),
|
||||
'success': is_alive
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'ip_address': ip_address,
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
'success': False
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def scan_network(cidr: str,
|
||||
enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True,
|
||||
timeout: int = 2,
|
||||
max_concurrent: int = 50) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
扫描整个网段(并发)
|
||||
"""
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
ips = [str(ip) for ip in network.hosts()]
|
||||
|
||||
if not ips:
|
||||
return []
|
||||
|
||||
# 并发执行;并发数随网段大小自适应
|
||||
worker_count = min(max_concurrent, len(ips))
|
||||
results: List[Dict[str, Any]] = []
|
||||
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
||||
future_to_ip = {
|
||||
executor.submit(
|
||||
EnhancedScanService.comprehensive_scan,
|
||||
ip,
|
||||
enable_ping=enable_ping,
|
||||
enable_arp=enable_arp,
|
||||
enable_dns=enable_dns,
|
||||
timeout=timeout
|
||||
): ip
|
||||
for ip in ips
|
||||
}
|
||||
for future in as_completed(future_to_ip):
|
||||
try:
|
||||
results.append(future.result())
|
||||
except Exception as e:
|
||||
ip = future_to_ip[future]
|
||||
results.append({
|
||||
'ip_address': ip,
|
||||
'status': 'offline',
|
||||
'mac_address': None,
|
||||
'hostname': None,
|
||||
'vendor': None,
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def update_ip_from_scan_result(db: Session, scan_result: Dict[str, Any]):
|
||||
"""根据扫描结果更新IP信息"""
|
||||
ip_address = scan_result.get('ip_address')
|
||||
if not ip_address:
|
||||
return
|
||||
|
||||
db_ip = db.query(IPAddress).filter(IPAddress.ip_address == ip_address).first()
|
||||
|
||||
# 如果IP不存在,尝试自动创建
|
||||
if not db_ip:
|
||||
# 从ip_address推断network_id
|
||||
network = db.query(Network).filter(
|
||||
Network.cidr.op('@>')((ip_address + '/32').encode('utf-8'))
|
||||
).first()
|
||||
if not network:
|
||||
# 尝试精确匹配 network.cidr
|
||||
for net in db.query(Network).all():
|
||||
try:
|
||||
if ip_address in ipaddress.ip_network(net.cidr, strict=False):
|
||||
network = net
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not network:
|
||||
logger.warning(f"无法为扫描到的IP {ip_address} 找到所属网段,跳过创建")
|
||||
return
|
||||
|
||||
db_ip = IPAddress(ip_address=ip_address, network_id=network.id)
|
||||
db.add(db_ip)
|
||||
db.commit()
|
||||
db.refresh(db_ip)
|
||||
|
||||
# 更新状态
|
||||
status = scan_result.get('status', 'offline')
|
||||
if status == 'online':
|
||||
db_ip.status = IPStatus.ONLINE
|
||||
elif status == 'offline' and db_ip.status == IPStatus.ONLINE:
|
||||
db_ip.status = IPStatus.OFFLINE
|
||||
|
||||
# 更新 MAC 地址
|
||||
mac = scan_result.get('mac_address')
|
||||
if mac:
|
||||
db_ip.mac_address = mac
|
||||
|
||||
# 更新主机名
|
||||
hostname = scan_result.get('hostname')
|
||||
if hostname:
|
||||
db_ip.hostname = hostname
|
||||
|
||||
# 更新厂商信息
|
||||
vendor = scan_result.get('vendor')
|
||||
if vendor:
|
||||
db_ip.vendor = vendor
|
||||
|
||||
# 更新最后发现时间
|
||||
if scan_result.get('success'):
|
||||
db_ip.last_seen = datetime.utcnow()
|
||||
|
||||
return db_ip
|
||||
|
||||
@staticmethod
|
||||
def bulk_update_ips_from_scan(db: Session, scan_results: List[Dict[str, Any]]):
|
||||
"""批量更新IP信息"""
|
||||
online_count = 0
|
||||
mac_found_count = 0
|
||||
hostname_found_count = 0
|
||||
created_count = 0
|
||||
|
||||
for result in scan_results:
|
||||
ip = EnhancedScanService.update_ip_from_scan_result(db, result)
|
||||
if ip:
|
||||
if result.get('success'):
|
||||
online_count += 1
|
||||
if result.get('mac_address'):
|
||||
mac_found_count += 1
|
||||
if result.get('hostname'):
|
||||
hostname_found_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'online_count': online_count,
|
||||
'mac_found_count': mac_found_count,
|
||||
'hostname_found_count': hostname_found_count,
|
||||
'total_scanned': len(scan_results)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.network import IPAddress as IPAddressModel
|
||||
from app.models.network import Network as NetworkModel
|
||||
from app.models.network import IPStatus
|
||||
from app.schemas.network import IPAddressUpdate, IPAddressCreate
|
||||
|
||||
|
||||
class IPService:
|
||||
"""IP地址管理服务"""
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(db: Session, ip_id: int) -> Optional[IPAddressModel]:
|
||||
"""根据ID获取IP"""
|
||||
return db.query(IPAddressModel).filter(IPAddressModel.id == ip_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_by_ip(db: Session, ip_address: str) -> Optional[IPAddressModel]:
|
||||
"""根据IP地址获取"""
|
||||
return db.query(IPAddressModel).filter(IPAddressModel.ip_address == ip_address).first()
|
||||
|
||||
@staticmethod
|
||||
def get_list(
|
||||
db: Session,
|
||||
network_id: Optional[int] = None,
|
||||
status: Optional[IPStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search: Optional[str] = None
|
||||
) -> tuple[int, List[IPAddressModel]]:
|
||||
"""获取IP列表"""
|
||||
query = db.query(IPAddressModel)
|
||||
|
||||
if network_id:
|
||||
query = query.filter(IPAddressModel.network_id == network_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(IPAddressModel.status == status)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
(IPAddressModel.ip_address.like(search_pattern)) |
|
||||
(IPAddressModel.mac_address.like(search_pattern)) |
|
||||
(IPAddressModel.hostname.like(search_pattern)) |
|
||||
(IPAddressModel.owner.like(search_pattern))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(IPAddressModel.ip_address).offset(skip).limit(limit).all()
|
||||
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, ip_in: IPAddressCreate) -> IPAddressModel:
|
||||
"""手动创建IP(通常由网段创建自动创建)"""
|
||||
db_ip = IPAddressModel(**ip_in.model_dump())
|
||||
db.add(db_ip)
|
||||
db.commit()
|
||||
db.refresh(db_ip)
|
||||
return db_ip
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, ip_id: int, ip_in: IPAddressUpdate) -> Optional[IPAddressModel]:
|
||||
"""更新IP信息"""
|
||||
db_ip = IPService.get_by_id(db, ip_id)
|
||||
if not db_ip:
|
||||
return None
|
||||
|
||||
update_data = ip_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_ip, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_ip)
|
||||
return db_ip
|
||||
|
||||
@staticmethod
|
||||
def update_status(db: Session, ip_address: str, status: IPStatus,
|
||||
mac_address: Optional[str] = None,
|
||||
hostname: Optional[str] = None) -> Optional[IPAddressModel]:
|
||||
"""更新IP状态和扫描信息"""
|
||||
db_ip = IPService.get_by_ip(db, ip_address)
|
||||
if not db_ip:
|
||||
return None
|
||||
|
||||
db_ip.status = status
|
||||
db_ip.last_seen = datetime.utcnow()
|
||||
|
||||
if mac_address:
|
||||
db_ip.mac_address = mac_address
|
||||
|
||||
if hostname:
|
||||
db_ip.hostname = hostname
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_ip)
|
||||
return db_ip
|
||||
|
||||
@staticmethod
|
||||
def set_reserved(db: Session, ip_id: int, reserved: bool = True) -> Optional[IPAddressModel]:
|
||||
"""设置/取消保留IP"""
|
||||
db_ip = IPService.get_by_id(db, ip_id)
|
||||
if not db_ip:
|
||||
return None
|
||||
|
||||
db_ip.status = IPStatus.RESERVED if reserved else IPStatus.AVAILABLE
|
||||
db.commit()
|
||||
db.refresh(db_ip)
|
||||
return db_ip
|
||||
|
||||
@staticmethod
|
||||
def bulk_update_status(db: Session, results: List[Dict[str, Any]]):
|
||||
"""批量更新IP状态
|
||||
|
||||
Args:
|
||||
results: 扫描结果列表,每个元素包含 ip_address, status, mac_address, hostname
|
||||
"""
|
||||
for result in results:
|
||||
ip_address = result.get('ip_address')
|
||||
status = result.get('status')
|
||||
mac_address = result.get('mac_address')
|
||||
hostname = result.get('hostname')
|
||||
|
||||
db_ip = IPService.get_by_ip(db, ip_address)
|
||||
if db_ip:
|
||||
db_ip.status = status
|
||||
db_ip.last_seen = datetime.utcnow()
|
||||
if mac_address:
|
||||
db_ip.mac_address = mac_address
|
||||
if hostname:
|
||||
db_ip.hostname = hostname
|
||||
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_by_network(db: Session, network_id: int) -> List[IPAddressModel]:
|
||||
"""获取指定网段下的所有IP"""
|
||||
return db.query(IPAddressModel).filter(
|
||||
IPAddressModel.network_id == network_id
|
||||
).order_by(IPAddressModel.ip_address).all()
|
||||
@@ -0,0 +1,177 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, case
|
||||
import ipaddress
|
||||
|
||||
from app.models.network import Network as NetworkModel
|
||||
from app.models.network import IPAddress as IPAddressModel
|
||||
from app.models.network import IPStatus
|
||||
from app.schemas.network import NetworkCreate, NetworkUpdate, NetworkStats
|
||||
|
||||
|
||||
class NetworkService:
|
||||
"""网段管理服务"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_total_ips(cidr: str) -> int:
|
||||
"""计算网段的总IP数量"""
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
return network.num_addresses
|
||||
|
||||
@staticmethod
|
||||
def get_network_addresses(cidr: str) -> List[str]:
|
||||
"""获取网段内所有IP地址列表"""
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
return [str(ip) for ip in network.hosts()]
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(db: Session, network_id: int) -> Optional[NetworkModel]:
|
||||
"""根据ID获取网段"""
|
||||
return db.query(NetworkModel).filter(NetworkModel.id == network_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_by_cidr(db: Session, cidr: str) -> Optional[NetworkModel]:
|
||||
"""根据CIDR获取网段"""
|
||||
return db.query(NetworkModel).filter(NetworkModel.cidr == cidr).first()
|
||||
|
||||
@staticmethod
|
||||
def get_list(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
group_name: Optional[str] = None
|
||||
) -> tuple[int, List[NetworkModel]]:
|
||||
"""获取网段列表"""
|
||||
query = db.query(NetworkModel)
|
||||
|
||||
if group_name:
|
||||
query = query.filter(NetworkModel.group_name == group_name)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(NetworkModel.id.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, network_in: NetworkCreate) -> NetworkModel:
|
||||
"""创建新网段"""
|
||||
total_ips = NetworkService.calculate_total_ips(network_in.cidr)
|
||||
|
||||
db_network = NetworkModel(
|
||||
cidr=network_in.cidr,
|
||||
name=network_in.name,
|
||||
description=network_in.description,
|
||||
group_name=network_in.group_name,
|
||||
gateway=network_in.gateway,
|
||||
vlan_id=network_in.vlan_id,
|
||||
total_ips=total_ips,
|
||||
used_ips=0,
|
||||
reserved_ips=0
|
||||
)
|
||||
|
||||
db.add(db_network)
|
||||
db.flush()
|
||||
|
||||
# 自动创建该网段下的所有IP记录
|
||||
NetworkService._create_ip_addresses(db, db_network)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_network)
|
||||
return db_network
|
||||
|
||||
@staticmethod
|
||||
def _create_ip_addresses(db: Session, network: NetworkModel):
|
||||
"""为网段创建所有IP记录"""
|
||||
ip_addresses = NetworkService.get_network_addresses(network.cidr)
|
||||
|
||||
ip_records = []
|
||||
for ip in ip_addresses:
|
||||
# 标记网关为保留状态
|
||||
status = IPStatus.RESERVED if ip == network.gateway else IPStatus.AVAILABLE
|
||||
ip_records.append(
|
||||
IPAddressModel(
|
||||
network_id=network.id,
|
||||
ip_address=ip,
|
||||
status=status
|
||||
)
|
||||
)
|
||||
|
||||
db.bulk_save_objects(ip_records)
|
||||
|
||||
# 更新统计
|
||||
reserved_count = sum(1 for ip in ip_addresses if ip == network.gateway)
|
||||
network.reserved_ips = reserved_count
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, network_id: int, network_in: NetworkUpdate) -> Optional[NetworkModel]:
|
||||
"""更新网段信息"""
|
||||
db_network = NetworkService.get_by_id(db, network_id)
|
||||
if not db_network:
|
||||
return None
|
||||
|
||||
update_data = network_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_network, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_network)
|
||||
return db_network
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, network_id: int) -> bool:
|
||||
"""删除网段"""
|
||||
db_network = NetworkService.get_by_id(db, network_id)
|
||||
if not db_network:
|
||||
return False
|
||||
|
||||
db.delete(db_network)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_stats(db: Session, network_id: int) -> Optional[NetworkStats]:
|
||||
"""获取网段统计信息"""
|
||||
db_network = NetworkService.get_by_id(db, network_id)
|
||||
if not db_network:
|
||||
return None
|
||||
|
||||
# 实时计算IP状态 - 使用SUM(CASE)兼容MySQL
|
||||
stats = db.query(
|
||||
func.sum(case((IPAddressModel.status == IPStatus.ONLINE, 1), else_=0)).label('online'),
|
||||
func.sum(case((IPAddressModel.status == IPStatus.OFFLINE, 1), else_=0)).label('offline'),
|
||||
func.sum(case((IPAddressModel.status == IPStatus.RESERVED, 1), else_=0)).label('reserved'),
|
||||
).filter(IPAddressModel.network_id == network_id).first()
|
||||
|
||||
used_ips = int(stats.online or 0) + int(stats.offline or 0)
|
||||
reserved_ips = int(stats.reserved or 0)
|
||||
available_ips = db_network.total_ips - used_ips - reserved_ips
|
||||
usage_percent = (used_ips / db_network.total_ips * 100) if db_network.total_ips > 0 else 0
|
||||
|
||||
# 更新数据库统计
|
||||
db_network.used_ips = used_ips
|
||||
db_network.reserved_ips = reserved_ips
|
||||
db.commit()
|
||||
|
||||
return NetworkStats(
|
||||
id=db_network.id,
|
||||
cidr=db_network.cidr,
|
||||
name=db_network.name,
|
||||
total_ips=db_network.total_ips,
|
||||
used_ips=used_ips,
|
||||
reserved_ips=reserved_ips,
|
||||
available_ips=available_ips,
|
||||
usage_percent=round(usage_percent, 2),
|
||||
group_name=db_network.group_name,
|
||||
vlan_id=db_network.vlan_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_all_stats(db: Session) -> List[NetworkStats]:
|
||||
"""获取所有网段的统计信息"""
|
||||
networks = db.query(NetworkModel).all()
|
||||
stats_list = []
|
||||
for network in networks:
|
||||
stats = NetworkService.get_stats(db, network.id)
|
||||
if stats:
|
||||
stats_list.append(stats)
|
||||
return stats_list
|
||||
@@ -0,0 +1,148 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import subprocess
|
||||
import ipaddress
|
||||
|
||||
from app.models.network import ScanTask, Network, IPAddress
|
||||
from app.models.network import TaskStatus, TaskType, IPStatus
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class ScanService:
|
||||
"""扫描服务"""
|
||||
|
||||
@staticmethod
|
||||
def ping_single_ip(ip_address: str, timeout: int = 2) -> Dict[str, Any]:
|
||||
"""
|
||||
对单个IP执行Ping扫描
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ping', '-c', '1', '-W', str(timeout), ip_address],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
is_alive = result.returncode == 0
|
||||
|
||||
return {
|
||||
'ip_address': ip_address,
|
||||
'status': 'online' if is_alive else 'offline',
|
||||
'response_time': None,
|
||||
'success': is_alive
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'ip_address': ip_address,
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
'success': False
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def ping_network(cidr: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
对整个网段执行Ping扫描(同步版本)
|
||||
"""
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
ips = [str(ip) for ip in network.hosts()]
|
||||
|
||||
results = []
|
||||
for ip in ips:
|
||||
result = ScanService.ping_single_ip(ip, settings.PING_TIMEOUT)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def create_scan_task(db: Session, task_type: TaskType,
|
||||
network_id: Optional[int] = None) -> ScanTask:
|
||||
"""
|
||||
创建扫描任务
|
||||
"""
|
||||
task = ScanTask(
|
||||
task_type=task_type,
|
||||
network_id=network_id,
|
||||
status=TaskStatus.PENDING,
|
||||
progress=0
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def update_task_status(db: Session, task_id: int, status: TaskStatus,
|
||||
progress: int = None, total_count: int = None,
|
||||
success_count: int = None, failed_count: int = None,
|
||||
error_message: str = None):
|
||||
"""
|
||||
更新任务状态
|
||||
"""
|
||||
task = db.query(ScanTask).filter(ScanTask.id == task_id).first()
|
||||
if not task:
|
||||
return
|
||||
|
||||
task.status = status
|
||||
if progress is not None:
|
||||
task.progress = progress
|
||||
if total_count is not None:
|
||||
task.total_count = total_count
|
||||
if success_count is not None:
|
||||
task.success_count = success_count
|
||||
if failed_count is not None:
|
||||
task.failed_count = failed_count
|
||||
if error_message:
|
||||
task.error_message = error_message
|
||||
|
||||
if status == TaskStatus.RUNNING and task.started_at is None:
|
||||
task.started_at = datetime.utcnow()
|
||||
elif status in [TaskStatus.COMPLETED, TaskStatus.FAILED]:
|
||||
task.completed_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_task_by_id(db: Session, task_id: int) -> Optional[ScanTask]:
|
||||
"""
|
||||
获取任务信息
|
||||
"""
|
||||
return db.query(ScanTask).filter(ScanTask.id == task_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_task_list(db: Session, skip: int = 0, limit: int = 50) -> tuple[int, List[ScanTask]]:
|
||||
"""
|
||||
获取任务列表
|
||||
"""
|
||||
query = db.query(ScanTask)
|
||||
total = query.count()
|
||||
items = query.order_by(ScanTask.id.desc()).offset(skip).limit(limit).all()
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def update_ip_status_from_scan_results(db: Session, results: List[Dict[str, Any]]):
|
||||
"""
|
||||
根据扫描结果更新IP状态
|
||||
"""
|
||||
online_count = 0
|
||||
offline_count = 0
|
||||
|
||||
for result in results:
|
||||
ip_address = result.get('ip_address')
|
||||
is_online = result.get('success', False)
|
||||
|
||||
db_ip = db.query(IPAddress).filter(IPAddress.ip_address == ip_address).first()
|
||||
if db_ip:
|
||||
if is_online:
|
||||
db_ip.status = IPStatus.ONLINE
|
||||
db_ip.last_seen = datetime.utcnow()
|
||||
online_count += 1
|
||||
elif db_ip.status == IPStatus.ONLINE:
|
||||
# 曾经在线,现在不在线,标记为离线
|
||||
db_ip.status = IPStatus.OFFLINE
|
||||
offline_count += 1
|
||||
|
||||
db.commit()
|
||||
return online_count, offline_count
|
||||
@@ -0,0 +1,520 @@
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from pysnmp.hlapi import (
|
||||
SnmpEngine, CommunityData, UsmUserData,
|
||||
UdpTransportTarget, ContextData,
|
||||
ObjectType, ObjectIdentity,
|
||||
bulkCmd, getCmd, nextCmd
|
||||
)
|
||||
from pysnmp.proto.rfc1902 import OctetString, Integer32, Counter32, Counter64, Gauge32
|
||||
|
||||
from app.models.snmp import (
|
||||
SNMPCredential, NetworkDevice, ARPEntry, MACAddressTable, DeviceInterface,
|
||||
SNMPVersion, SNMPAuthProtocol, SNMPPrivProtocol
|
||||
)
|
||||
from app.services.enhanced_scan_service import EnhancedScanService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SNMPService:
|
||||
"""SNMP 服务"""
|
||||
|
||||
# OID 定义
|
||||
OID_SYSTEM = '1.3.6.1.2.1.1'
|
||||
OID_SYS_DESCR = '1.3.6.1.2.1.1.1.0'
|
||||
OID_SYS_NAME = '1.3.6.1.2.1.1.5.0'
|
||||
OID_SYS_LOCATION = '1.3.6.1.2.1.1.6.0'
|
||||
|
||||
# 接口相关 OID
|
||||
OID_IF_TABLE = '1.3.6.1.2.1.2.2'
|
||||
OID_IF_INDEX = '1.3.6.1.2.1.2.2.1.1'
|
||||
OID_IF_DESCR = '1.3.6.1.2.1.2.2.1.2'
|
||||
OID_IF_TYPE = '1.3.6.1.2.1.2.2.1.3'
|
||||
OID_IF_MTU = '1.3.6.1.2.1.2.2.1.4'
|
||||
OID_IF_SPEED = '1.3.6.1.2.1.2.2.1.5'
|
||||
OID_IF_PHYS_ADDRESS = '1.3.6.1.2.1.2.2.1.6'
|
||||
OID_IF_ADMIN_STATUS = '1.3.6.1.2.1.2.2.1.7'
|
||||
OID_IF_OPER_STATUS = '1.3.6.1.2.1.2.2.1.8'
|
||||
OID_IF_IN_OCTETS = '1.3.6.1.2.1.2.2.1.10'
|
||||
OID_IF_OUT_OCTETS = '1.3.6.1.2.1.2.2.1.16'
|
||||
OID_IF_IN_ERRORS = '1.3.6.1.2.1.2.2.1.14'
|
||||
OID_IF_OUT_ERRORS = '1.3.6.1.2.1.2.2.1.20'
|
||||
|
||||
# IP 地址表
|
||||
OID_IP_ADDR_TABLE = '1.3.6.1.2.1.4.20'
|
||||
OID_IP_ADDR_ENTRIES = '1.3.6.1.2.1.4.20.1.1'
|
||||
OID_IP_ADDR_IF_INDEX = '1.3.6.1.2.1.4.20.1.2'
|
||||
OID_IP_ADDR_NETMASK = '1.3.6.1.2.1.4.20.1.3'
|
||||
|
||||
# ARP 表 (ipNetToMediaTable)
|
||||
OID_IP_NET_TO_MEDIA_PHYS_ADDRESS = '1.3.6.1.2.1.4.22.1.2'
|
||||
OID_IP_NET_TO_MEDIA_IF_INDEX = '1.3.6.1.2.1.4.22.1.1'
|
||||
OID_IP_NET_TO_MEDIA_NET_ADDRESS = '1.3.6.1.2.1.4.22.1.3'
|
||||
OID_IP_NET_TO_MEDIA_TYPE = '1.3.6.1.2.1.4.22.1.4'
|
||||
|
||||
# DOT1D 桥接 MIB (MAC 地址表)
|
||||
OID_DOT1D_TP_FDB_PORT = '1.3.6.1.2.1.17.4.3.1.2'
|
||||
OID_DOT1D_TP_FDB_STATUS = '1.3.6.1.2.1.17.4.3.1.3'
|
||||
|
||||
# VLAN 相关 (Cisco 特定)
|
||||
OID_VTP_VLAN_STATE = '1.3.6.1.4.1.9.9.46.1.3.1.1.2'
|
||||
|
||||
@staticmethod
|
||||
def _create_auth_data(credential: SNMPCredential):
|
||||
"""创建 SNMP 认证数据"""
|
||||
if credential.version == SNMPVersion.V2C:
|
||||
if not credential.community_string:
|
||||
raise ValueError("SNMPv2c 凭据缺少 community_string")
|
||||
return CommunityData(credential.community_string)
|
||||
else: # V3
|
||||
if not credential.username:
|
||||
raise ValueError("SNMPv3 凭据缺少 username(V3 强制要求 1-32 字符)")
|
||||
|
||||
auth_protocol_map = {
|
||||
SNMPAuthProtocol.MD5: 'MD5',
|
||||
SNMPAuthProtocol.SHA: 'SHA',
|
||||
SNMPAuthProtocol.SHA256: 'SHA256',
|
||||
}
|
||||
priv_protocol_map = {
|
||||
SNMPPrivProtocol.DES: 'DES',
|
||||
SNMPPrivProtocol.AES: 'AES',
|
||||
SNMPPrivProtocol.AES256: 'AES256',
|
||||
}
|
||||
|
||||
auth_proto = auth_protocol_map.get(credential.auth_protocol) if credential.auth_protocol else None
|
||||
priv_proto = priv_protocol_map.get(credential.priv_protocol) if credential.priv_protocol else None
|
||||
|
||||
return UsmUserData(
|
||||
userName=credential.username,
|
||||
authProtocol=auth_proto,
|
||||
authKey=credential.auth_password,
|
||||
privProtocol=priv_proto,
|
||||
privKey=credential.priv_password
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_transport_target(ip_address: str, port: int = 161, timeout: int = 3, retries: int = 2):
|
||||
"""创建传输目标"""
|
||||
return UdpTransportTarget((ip_address, port), timeout=timeout, retries=retries)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_mac(mac_bytes) -> Optional[str]:
|
||||
"""将 MAC 地址字节转换为标准格式 AA:BB:CC:DD:EE:FF"""
|
||||
if not mac_bytes:
|
||||
return None
|
||||
|
||||
if isinstance(mac_bytes, OctetString):
|
||||
mac_bytes = mac_bytes.asOctets()
|
||||
|
||||
if len(mac_bytes) == 6:
|
||||
return ':'.join(f'{b:02X}' for b in mac_bytes)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def test_connection(device: NetworkDevice, credential: SNMPCredential) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
测试 SNMP 连接
|
||||
返回: (成功, 设备信息字典)
|
||||
"""
|
||||
try:
|
||||
auth_data = SNMPService._create_auth_data(credential)
|
||||
transport = SNMPService._get_transport_target(
|
||||
device.ip_address,
|
||||
port=device.port,
|
||||
timeout=credential.timeout,
|
||||
retries=credential.retries
|
||||
)
|
||||
|
||||
errorIndication, errorStatus, errorIndex, varBinds = next(
|
||||
getCmd(SnmpEngine(), auth_data, transport, ContextData(),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_DESCR)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_NAME)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_LOCATION)))
|
||||
)
|
||||
|
||||
if errorIndication:
|
||||
return False, {'error': str(errorIndication)}
|
||||
if errorStatus:
|
||||
return False, {'error': f'{errorStatus.prettyPrint()} at {errorIndex and varBinds[int(errorIndex) - 1][0] or "?"}'}
|
||||
|
||||
sys_info = {}
|
||||
for varBind in varBinds:
|
||||
oid = str(varBind[0])
|
||||
value = varBind[1].prettyPrint()
|
||||
|
||||
if SNMPService.OID_SYS_DESCR in oid:
|
||||
sys_info['sys_descr'] = value
|
||||
elif SNMPService.OID_SYS_NAME in oid:
|
||||
sys_info['sys_name'] = value
|
||||
elif SNMPService.OID_SYS_LOCATION in oid:
|
||||
sys_info['sys_location'] = value
|
||||
|
||||
return True, sys_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SNMP 连接测试失败: {e}")
|
||||
return False, {'error': str(e)}
|
||||
|
||||
@staticmethod
|
||||
def get_arp_table(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取 ARP 表 (IP -> MAC 映射)
|
||||
"""
|
||||
arp_entries = []
|
||||
|
||||
try:
|
||||
auth_data = SNMPService._create_auth_data(credential)
|
||||
transport = SNMPService._get_transport_target(
|
||||
device.ip_address,
|
||||
port=device.port,
|
||||
timeout=credential.timeout,
|
||||
retries=credential.retries
|
||||
)
|
||||
|
||||
# 使用 bulkCmd 获取 ARP 表
|
||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||||
SnmpEngine(), auth_data, transport, ContextData(),
|
||||
0, 50, # nonRepeaters, maxRepetitions
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_PHYS_ADDRESS)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_NET_ADDRESS)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_IF_INDEX)),
|
||||
lexicographicMode=False
|
||||
):
|
||||
if errorIndication:
|
||||
logger.error(f"SNMP 错误: {errorIndication}")
|
||||
break
|
||||
|
||||
if errorStatus:
|
||||
logger.error(f"SNMP 错误: {errorStatus}")
|
||||
break
|
||||
|
||||
# 解析结果
|
||||
arp_data = {}
|
||||
for varBind in varBinds:
|
||||
oid = str(varBind[0])
|
||||
value = varBind[1]
|
||||
|
||||
# 提取 IP 地址 (OID 后缀: ifIndex.IP)
|
||||
parts = oid.split('.')
|
||||
if len(parts) >= 4:
|
||||
# 从 OID 中提取 IP 地址
|
||||
ip_parts = parts[-4:]
|
||||
ip_address = '.'.join(ip_parts)
|
||||
|
||||
if SNMPService.OID_IP_NET_TO_MEDIA_PHYS_ADDRESS in oid:
|
||||
mac = SNMPService._normalize_mac(value)
|
||||
if mac:
|
||||
if ip_address not in arp_data:
|
||||
arp_data[ip_address] = {}
|
||||
arp_data[ip_address]['mac_address'] = mac
|
||||
elif SNMPService.OID_IP_NET_TO_MEDIA_IF_INDEX in oid:
|
||||
if isinstance(value, Integer32):
|
||||
if_index = int(value)
|
||||
if ip_address not in arp_data:
|
||||
arp_data[ip_address] = {}
|
||||
arp_data[ip_address]['if_index'] = if_index
|
||||
|
||||
# 转换为列表
|
||||
for ip, data in arp_data.items():
|
||||
if 'mac_address' in data:
|
||||
entry = {
|
||||
'ip_address': ip,
|
||||
'mac_address': data.get('mac_address'),
|
||||
'interface': str(data.get('if_index', '')),
|
||||
'device_id': device.id
|
||||
}
|
||||
arp_entries.append(entry)
|
||||
|
||||
# 保存到数据库
|
||||
now = datetime.utcnow()
|
||||
for entry_data in arp_entries:
|
||||
# 查找是否已存在
|
||||
existing = db.query(ARPEntry).filter(
|
||||
ARPEntry.device_id == device.id,
|
||||
ARPEntry.ip_address == entry_data['ip_address']
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.mac_address = entry_data['mac_address']
|
||||
existing.last_seen = now
|
||||
else:
|
||||
arp_entry = ARPEntry(
|
||||
device_id=device.id,
|
||||
ip_address=entry_data['ip_address'],
|
||||
mac_address=entry_data['mac_address'],
|
||||
interface=entry_data['interface'],
|
||||
discovered_at=now,
|
||||
last_seen=now
|
||||
)
|
||||
db.add(arp_entry)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 更新 IP 资产台账中的 MAC 地址
|
||||
for entry in arp_entries:
|
||||
from app.models.network import IPAddress as IPAddressModel
|
||||
ip_addr = db.query(IPAddressModel).filter(
|
||||
IPAddressModel.ip_address == entry['ip_address']
|
||||
).first()
|
||||
if ip_addr and not ip_addr.mac_address:
|
||||
ip_addr.mac_address = entry['mac_address']
|
||||
# 识别厂商
|
||||
ip_addr.vendor = EnhancedScanService.get_mac_vendor(entry['mac_address'])
|
||||
|
||||
db.commit()
|
||||
device.last_successful_poll = now
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取 ARP 表失败: {e}", exc_info=True)
|
||||
|
||||
device.last_polled_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return arp_entries
|
||||
|
||||
@staticmethod
|
||||
def get_mac_address_table(device: NetworkDevice, credential: SNMPCredential, db: Session, vlan_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取 MAC 地址表 (MAC -> 端口 映射)
|
||||
"""
|
||||
mac_entries = []
|
||||
|
||||
try:
|
||||
auth_data = SNMPService._create_auth_data(credential)
|
||||
transport = SNMPService._get_transport_target(
|
||||
device.ip_address,
|
||||
port=device.port,
|
||||
timeout=credential.timeout,
|
||||
retries=credential.retries
|
||||
)
|
||||
|
||||
# 获取 MAC 地址表
|
||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||||
SnmpEngine(), auth_data, transport, ContextData(),
|
||||
0, 100,
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_PORT)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_STATUS)),
|
||||
lexicographicMode=False
|
||||
):
|
||||
if errorIndication:
|
||||
break
|
||||
|
||||
if errorStatus:
|
||||
break
|
||||
|
||||
for varBind in varBinds:
|
||||
oid = str(varBind[0])
|
||||
value = varBind[1]
|
||||
|
||||
# OID 后缀是 MAC 地址: .a.b.c.d.e.f
|
||||
parts = oid.split('.')
|
||||
if len(parts) >= 6:
|
||||
mac_parts = parts[-6:]
|
||||
mac_address = ':'.join(f'{int(p):02X}' for p in mac_parts)
|
||||
|
||||
if SNMPService.OID_DOT1D_TP_FDB_PORT in oid:
|
||||
port = int(value) if isinstance(value, Integer32) else None
|
||||
|
||||
entry = {
|
||||
'mac_address': mac_address,
|
||||
'port_number': port,
|
||||
'device_id': device.id,
|
||||
'vlan_id': vlan_id
|
||||
}
|
||||
mac_entries.append(entry)
|
||||
|
||||
# 保存到数据库
|
||||
now = datetime.utcnow()
|
||||
for entry_data in mac_entries:
|
||||
existing = db.query(MACAddressTable).filter(
|
||||
MACAddressTable.device_id == device.id,
|
||||
MACAddressTable.mac_address == entry_data['mac_address']
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.port_number = entry_data['port_number']
|
||||
existing.last_seen = now
|
||||
else:
|
||||
mac_entry = MACAddressTable(
|
||||
device_id=device.id,
|
||||
mac_address=entry_data['mac_address'],
|
||||
port_number=entry_data['port_number'],
|
||||
vlan_id=vlan_id,
|
||||
discovered_at=now,
|
||||
last_seen=now
|
||||
)
|
||||
db.add(mac_entry)
|
||||
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取 MAC 地址表失败: {e}", exc_info=True)
|
||||
|
||||
return mac_entries
|
||||
|
||||
@staticmethod
|
||||
def get_interfaces(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取设备接口信息
|
||||
"""
|
||||
interfaces = []
|
||||
|
||||
try:
|
||||
auth_data = SNMPService._create_auth_data(credential)
|
||||
transport = SNMPService._get_transport_target(
|
||||
device.ip_address,
|
||||
port=device.port,
|
||||
timeout=credential.timeout,
|
||||
retries=credential.retries
|
||||
)
|
||||
|
||||
# 获取接口信息
|
||||
interface_data = {}
|
||||
|
||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||||
SnmpEngine(), auth_data, transport, ContextData(),
|
||||
0, 100,
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_DESCR)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_TYPE)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_MTU)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_SPEED)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_PHYS_ADDRESS)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_ADMIN_STATUS)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_OPER_STATUS)),
|
||||
lexicographicMode=False
|
||||
):
|
||||
if errorIndication or errorStatus:
|
||||
break
|
||||
|
||||
for varBind in varBinds:
|
||||
oid = str(varBind[0])
|
||||
value = varBind[1]
|
||||
parts = oid.split('.')
|
||||
if_index = parts[-1]
|
||||
|
||||
if if_index not in interface_data:
|
||||
interface_data[if_index] = {'if_index': int(if_index)}
|
||||
|
||||
if SNMPService.OID_IF_DESCR in oid:
|
||||
interface_data[if_index]['if_descr'] = value.prettyPrint()
|
||||
elif SNMPService.OID_IF_TYPE in oid:
|
||||
interface_data[if_index]['if_type'] = str(value)
|
||||
elif SNMPService.OID_IF_MTU in oid:
|
||||
interface_data[if_index]['if_mtu'] = int(value) if isinstance(value, Integer32) else None
|
||||
elif SNMPService.OID_IF_SPEED in oid:
|
||||
interface_data[if_index]['if_speed'] = int(value) if isinstance(value, (Integer32, Gauge32)) else None
|
||||
elif SNMPService.OID_IF_PHYS_ADDRESS in oid:
|
||||
interface_data[if_index]['if_phys_address'] = SNMPService._normalize_mac(value)
|
||||
elif SNMPService.OID_IF_ADMIN_STATUS in oid:
|
||||
status_map = {1: 'up', 2: 'down', 3: 'testing'}
|
||||
interface_data[if_index]['if_admin_status'] = status_map.get(int(value), 'unknown')
|
||||
elif SNMPService.OID_IF_OPER_STATUS in oid:
|
||||
status_map = {1: 'up', 2: 'down', 3: 'testing'}
|
||||
interface_data[if_index]['if_oper_status'] = status_map.get(int(value), 'unknown')
|
||||
|
||||
# 转换为列表并保存
|
||||
now = datetime.utcnow()
|
||||
for if_index, data in interface_data.items():
|
||||
interfaces.append(data)
|
||||
|
||||
existing = db.query(DeviceInterface).filter(
|
||||
DeviceInterface.device_id == device.id,
|
||||
DeviceInterface.if_index == int(if_index)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
for key, val in data.items():
|
||||
if hasattr(existing, key):
|
||||
setattr(existing, key, val)
|
||||
existing.last_polled_at = now
|
||||
else:
|
||||
iface = DeviceInterface(
|
||||
device_id=device.id,
|
||||
if_index=int(if_index),
|
||||
if_descr=data.get('if_descr'),
|
||||
if_type=data.get('if_type'),
|
||||
if_mtu=data.get('if_mtu'),
|
||||
if_speed=data.get('if_speed'),
|
||||
if_phys_address=data.get('if_phys_address'),
|
||||
if_admin_status=data.get('if_admin_status'),
|
||||
if_oper_status=data.get('if_oper_status'),
|
||||
last_polled_at=now
|
||||
)
|
||||
db.add(iface)
|
||||
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取接口信息失败: {e}", exc_info=True)
|
||||
|
||||
return interfaces
|
||||
|
||||
@staticmethod
|
||||
def create_credential(db: Session, name: str, version: SNMPVersion, **kwargs) -> SNMPCredential:
|
||||
"""创建 SNMP 凭据"""
|
||||
credential = SNMPCredential(
|
||||
name=name,
|
||||
version=version,
|
||||
**kwargs
|
||||
)
|
||||
db.add(credential)
|
||||
db.commit()
|
||||
db.refresh(credential)
|
||||
return credential
|
||||
|
||||
@staticmethod
|
||||
def create_device(db: Session, name: str, ip_address: str, credential_id: Optional[int] = None, **kwargs) -> NetworkDevice:
|
||||
"""创建网络设备"""
|
||||
device = NetworkDevice(
|
||||
name=name,
|
||||
ip_address=ip_address,
|
||||
snmp_credential_id=credential_id,
|
||||
**kwargs
|
||||
)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
return device
|
||||
|
||||
@staticmethod
|
||||
def poll_device(db: Session, device_id: int) -> Dict[str, Any]:
|
||||
"""轮询设备,获取所有信息"""
|
||||
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
||||
if not device:
|
||||
return {'error': '设备不存在'}
|
||||
|
||||
if not device.snmp_credential:
|
||||
return {'error': '设备未配置 SNMP 凭据'}
|
||||
|
||||
credential = device.snmp_credential
|
||||
|
||||
result = {
|
||||
'device_id': device_id,
|
||||
'device_name': device.name,
|
||||
'arp_entries': [],
|
||||
'mac_entries': [],
|
||||
'interfaces': []
|
||||
}
|
||||
|
||||
# 获取 ARP 表
|
||||
arp_entries = SNMPService.get_arp_table(device, credential, db)
|
||||
result['arp_entries'] = arp_entries
|
||||
result['arp_count'] = len(arp_entries)
|
||||
|
||||
# 获取 MAC 地址表
|
||||
mac_entries = SNMPService.get_mac_address_table(device, credential, db)
|
||||
result['mac_entries'] = mac_entries
|
||||
result['mac_count'] = len(mac_entries)
|
||||
|
||||
# 获取接口信息
|
||||
interfaces = SNMPService.get_interfaces(device, credential, db)
|
||||
result['interfaces'] = interfaces
|
||||
result['interface_count'] = len(interfaces)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,420 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken
|
||||
from app.core.security import SecurityUtils
|
||||
|
||||
|
||||
# 默认权限项 + 角色权限映射
|
||||
DEFAULT_PERMISSIONS = [
|
||||
# 网段
|
||||
("network:view", "查看网段", "network"),
|
||||
("network:create", "创建网段", "network"),
|
||||
("network:edit", "编辑网段", "network"),
|
||||
("network:delete", "删除网段", "network"),
|
||||
# IP
|
||||
("ip:view", "查看 IP", "ip"),
|
||||
("ip:edit", "编辑 IP", "ip"),
|
||||
# 扫描
|
||||
("scan:run", "执行扫描", "scan"),
|
||||
("scan:view", "查看扫描结果", "scan"),
|
||||
# SNMP
|
||||
("snmp:view", "查看 SNMP", "snmp"),
|
||||
("snmp:edit", "配置 SNMP", "snmp"),
|
||||
# 告警
|
||||
("alert:view", "查看告警", "alert"),
|
||||
("alert:ack", "确认告警", "alert"),
|
||||
("alert:resolve", "解决告警", "alert"),
|
||||
# 报告
|
||||
("report:export", "导出报告", "report"),
|
||||
# 用户管理
|
||||
("user:view", "查看用户", "system"),
|
||||
("user:create", "创建用户", "system"),
|
||||
("user:edit", "编辑用户", "system"),
|
||||
("user:delete", "删除用户", "system"),
|
||||
# 审计
|
||||
("audit:view", "查看审计日志", "system"),
|
||||
]
|
||||
|
||||
|
||||
# 角色 -> 权限代码集合
|
||||
DEFAULT_ROLE_PERMISSIONS = {
|
||||
UserRole.SUPER_ADMIN: {p[0] for p in DEFAULT_PERMISSIONS}, # 全部
|
||||
UserRole.ADMIN: {
|
||||
"network:view", "network:create", "network:edit", "network:delete",
|
||||
"ip:view", "ip:edit",
|
||||
"scan:run", "scan:view",
|
||||
"snmp:view", "snmp:edit",
|
||||
"alert:view", "alert:ack", "alert:resolve",
|
||||
"report:export",
|
||||
"user:view", "audit:view",
|
||||
},
|
||||
UserRole.OPERATOR: {
|
||||
"network:view",
|
||||
"ip:view", "ip:edit",
|
||||
"scan:run", "scan:view",
|
||||
"snmp:view",
|
||||
"alert:view", "alert:ack",
|
||||
"report:export",
|
||||
},
|
||||
UserRole.VIEWER: {
|
||||
"network:view",
|
||||
"ip:view",
|
||||
"scan:view",
|
||||
"snmp:view",
|
||||
"alert:view",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class UserService:
|
||||
"""用户服务"""
|
||||
|
||||
@staticmethod
|
||||
def init_default_permissions(db: Session) -> int:
|
||||
"""初始化默认权限项和角色权限映射(已存在则跳过)"""
|
||||
created = 0
|
||||
perm_map = {}
|
||||
for code, name, category in DEFAULT_PERMISSIONS:
|
||||
existing = db.query(Permission).filter(Permission.code == code).first()
|
||||
if existing:
|
||||
perm_map[code] = existing
|
||||
continue
|
||||
p = Permission(code=code, name=name, category=category)
|
||||
db.add(p)
|
||||
db.flush()
|
||||
perm_map[code] = p
|
||||
created += 1
|
||||
|
||||
# 角色 -> 权限关联
|
||||
for role, codes in DEFAULT_ROLE_PERMISSIONS.items():
|
||||
for code in codes:
|
||||
if code not in perm_map:
|
||||
continue
|
||||
pid = perm_map[code].id
|
||||
link_existing = db.query(RolePermission).filter(
|
||||
RolePermission.role == role,
|
||||
RolePermission.permission_id == pid
|
||||
).first()
|
||||
if link_existing:
|
||||
continue
|
||||
db.add(RolePermission(role=role, permission_id=pid))
|
||||
|
||||
db.commit()
|
||||
return created
|
||||
|
||||
@staticmethod
|
||||
def get_user_permissions(db: Session, user: User) -> set:
|
||||
"""获取用户拥有的全部权限代码集合"""
|
||||
if user.role == UserRole.SUPER_ADMIN:
|
||||
return {p.code for p in db.query(Permission).all()}
|
||||
# 其他角色查 role_permissions 关联表
|
||||
rows = db.query(Permission.code).join(
|
||||
RolePermission, RolePermission.permission_id == Permission.id
|
||||
).filter(RolePermission.role == user.role).all()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
@staticmethod
|
||||
def init_default_admin(db: Session):
|
||||
"""初始化默认管理员账户"""
|
||||
admin_exists = db.query(User).filter(User.username == "admin").first()
|
||||
if not admin_exists:
|
||||
admin = User(
|
||||
username="admin",
|
||||
email="admin@ipam.local",
|
||||
real_name="系统管理员",
|
||||
password_hash=SecurityUtils.hash_password("admin123"),
|
||||
role=UserRole.SUPER_ADMIN,
|
||||
status=UserStatus.ACTIVE
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
db.refresh(admin)
|
||||
return admin
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def authenticate(db: Session, username: str, password: str) -> Optional[User]:
|
||||
"""用户认证"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not SecurityUtils.verify_password(password, user.password_hash):
|
||||
# 记录登录失败
|
||||
user.login_failed_count += 1
|
||||
# 登录失败5次锁定账号
|
||||
if user.login_failed_count >= 5:
|
||||
user.status = UserStatus.LOCKED
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
# 登录成功,重置失败计数
|
||||
if user.login_failed_count > 0:
|
||||
user.login_failed_count = 0
|
||||
db.commit()
|
||||
|
||||
# 检查用户状态
|
||||
if user.status != UserStatus.ACTIVE:
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def create_user(db: Session,
|
||||
username: str,
|
||||
password: str,
|
||||
email: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
real_name: Optional[str] = None,
|
||||
role: UserRole = UserRole.VIEWER,
|
||||
created_by: Optional[int] = None) -> User:
|
||||
"""创建用户"""
|
||||
# 检查用户名是否存在
|
||||
existing = db.query(User).filter(User.username == username).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="用户名已存在"
|
||||
)
|
||||
|
||||
# 检查邮箱是否存在
|
||||
if email:
|
||||
existing_email = db.query(User).filter(User.email == email).first()
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="邮箱已被使用"
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
phone=phone,
|
||||
real_name=real_name,
|
||||
password_hash=SecurityUtils.hash_password(password),
|
||||
role=role,
|
||||
status=UserStatus.ACTIVE,
|
||||
created_by=created_by
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def update_login_info(db: Session, user_id: int, ip_address: str = None):
|
||||
"""更新登录信息"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user:
|
||||
user.last_login_at = datetime.utcnow()
|
||||
user.last_login_ip = ip_address
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
|
||||
"""根据ID获取用户"""
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_username(db: Session, username: str) -> Optional[User]:
|
||||
"""根据用户名获取用户"""
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
|
||||
@staticmethod
|
||||
def get_users(db: Session, skip: int = 0, limit: int = 50, status: UserStatus = None, role: UserRole = None) -> tuple[int, List[User]]:
|
||||
"""获取用户列表"""
|
||||
query = db.query(User)
|
||||
|
||||
if status:
|
||||
query = query.filter(User.status == status)
|
||||
if role:
|
||||
query = query.filter(User.role == role)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(User.id.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def change_password(db: Session, user_id: int, old_password: str, new_password: str) -> bool:
|
||||
"""修改密码"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if not SecurityUtils.verify_password(old_password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="原密码不正确"
|
||||
)
|
||||
|
||||
user.password_hash = SecurityUtils.hash_password(new_password)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def reset_password(db: Session, user_id: int, new_password: str) -> bool:
|
||||
"""管理员重置密码"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user.password_hash = SecurityUtils.hash_password(new_password)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def update_user_status(db: Session, user_id: int, status: UserStatus) -> Optional[User]:
|
||||
"""更新用户状态"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
user.status = status
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def delete_user(db: Session, user_id: int) -> bool:
|
||||
"""删除用户(软删除,实际标记为禁用)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user.status = UserStatus.INACTIVE
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def unlock_user(db: Session, user_id: int) -> Optional[User]:
|
||||
"""解锁被锁定的用户"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if user.status == UserStatus.LOCKED:
|
||||
user.status = UserStatus.ACTIVE
|
||||
user.login_failed_count = 0
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class TokenService:
|
||||
"""令牌服务"""
|
||||
|
||||
@staticmethod
|
||||
def create_tokens(db: Session, user: User, ip_address: str = None, user_agent: str = None) -> dict:
|
||||
"""创建访问令牌和刷新令牌"""
|
||||
access_token_expires = timedelta(minutes=120) # 访问令牌2小时
|
||||
access_token = SecurityUtils.create_access_token(
|
||||
data={
|
||||
"sub": user.username,
|
||||
"user_id": user.id,
|
||||
"role": user.role.value
|
||||
},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
refresh_token_expires = timedelta(days=7) # 刷新令牌7天
|
||||
refresh_token_value = SecurityUtils.create_refresh_token(
|
||||
data={
|
||||
"sub": user.username,
|
||||
"user_id": user.id
|
||||
},
|
||||
expires_delta=refresh_token_expires
|
||||
)
|
||||
|
||||
# 保存刷新令牌
|
||||
refresh_token = RefreshToken(
|
||||
user_id=user.id,
|
||||
token=refresh_token_value,
|
||||
expires_at=datetime.utcnow() + refresh_token_expires,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
db.add(refresh_token)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token_value,
|
||||
"token_type": "bearer",
|
||||
"expires_in": int(access_token_expires.total_seconds())
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def refresh_access_token(db: Session, refresh_token: str) -> Optional[dict]:
|
||||
"""使用刷新令牌获取新的访问令牌"""
|
||||
try:
|
||||
payload = SecurityUtils.verify_token(refresh_token)
|
||||
username = payload.get("sub")
|
||||
user_id = payload.get("user_id")
|
||||
|
||||
# 检查刷新令牌是否有效且未被撤销
|
||||
token_record = db.query(RefreshToken).filter(
|
||||
RefreshToken.token == refresh_token,
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked == False,
|
||||
RefreshToken.expires_at > datetime.utcnow()
|
||||
).first()
|
||||
|
||||
if not token_record:
|
||||
return None
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or user.status != UserStatus.ACTIVE:
|
||||
return None
|
||||
|
||||
# 创建新的访问令牌
|
||||
access_token_expires = timedelta(minutes=120)
|
||||
access_token = SecurityUtils.create_access_token(
|
||||
data={
|
||||
"sub": user.username,
|
||||
"user_id": user.id,
|
||||
"role": user.role.value
|
||||
},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": int(access_token_expires.total_seconds())
|
||||
}
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def revoke_token(db: Session, refresh_token: str) -> bool:
|
||||
"""撤销刷新令牌"""
|
||||
token_record = db.query(RefreshToken).filter(RefreshToken.token == refresh_token).first()
|
||||
if token_record:
|
||||
token_record.revoked = True
|
||||
token_record.revoked_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def revoke_all_user_tokens(db: Session, user_id: int) -> int:
|
||||
"""撤销用户所有刷新令牌"""
|
||||
tokens = db.query(RefreshToken).filter(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked == False
|
||||
).all()
|
||||
|
||||
for token in tokens:
|
||||
token.revoked = True
|
||||
token.revoked_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
return len(tokens)
|
||||
@@ -0,0 +1,4 @@
|
||||
# app/tasks/__init__.py
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
__all__ = ['celery_app']
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,55 @@
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from app.core.config import settings
|
||||
|
||||
# 创建 Celery 应用
|
||||
celery_app = Celery(
|
||||
'ipam_tasks',
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND
|
||||
)
|
||||
|
||||
# 配置
|
||||
celery_app.conf.update(
|
||||
task_serializer='json',
|
||||
accept_content=['json'],
|
||||
result_serializer='json',
|
||||
timezone='Asia/Shanghai',
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_time_limit=30 * 60, # 任务超时30分钟
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_max_tasks_per_child=1000,
|
||||
)
|
||||
|
||||
# 必须在配置后导入并注册任务
|
||||
from app.tasks.scan_tasks import register_tasks
|
||||
_tasks = register_tasks(celery_app)
|
||||
|
||||
# 导出任务函数
|
||||
scan_network_task = _tasks['scan_network_task']
|
||||
scan_single_ip_task = _tasks['scan_single_ip_task']
|
||||
full_network_scan = _tasks['full_network_scan']
|
||||
update_all_statistics = _tasks['update_all_statistics']
|
||||
quick_status_update = _tasks['quick_status_update']
|
||||
|
||||
# 定时任务配置
|
||||
celery_app.conf.beat_schedule = {
|
||||
# 每小时执行一次全量扫描
|
||||
'full-scan-every-hour': {
|
||||
'task': 'app.tasks.scan_tasks.full_network_scan',
|
||||
'schedule': 3600.0, # 每小时
|
||||
'args': (True, True, True) # ping, arp, dns
|
||||
},
|
||||
# 每天凌晨2点执行一次完整扫描
|
||||
'full-scan-daily': {
|
||||
'task': 'app.tasks.scan_tasks.full_network_scan',
|
||||
'schedule': crontab(hour=2, minute=0),
|
||||
'args': (True, True, True)
|
||||
},
|
||||
# 每15分钟更新一次统计
|
||||
'update-stats-every-15min': {
|
||||
'task': 'app.tasks.scan_tasks.update_all_statistics',
|
||||
'schedule': 900.0, # 15分钟
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from celery import Task
|
||||
import logging
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.network_service import NetworkService
|
||||
from app.services.enhanced_scan_service import EnhancedScanService
|
||||
from app.services.scan_service import ScanService
|
||||
from app.models.network import TaskStatus, ScanTask, IPAddress
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_tasks(celery_app):
|
||||
"""
|
||||
注册任务到 Celery 应用
|
||||
"""
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def scan_network_task(self, network_id: int,
|
||||
enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True,
|
||||
timeout: int = 2):
|
||||
"""
|
||||
Celery 异步任务:扫描指定网段
|
||||
"""
|
||||
task_id = self.request.id
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# 获取网段信息
|
||||
network = NetworkService.get_by_id(db, network_id)
|
||||
if not network:
|
||||
logger.error(f"网段 {network_id} 不存在")
|
||||
return {'status': 'failed', 'error': '网段不存在'}
|
||||
|
||||
logger.info(f"开始扫描网段: {network.cidr}")
|
||||
|
||||
# 更新任务状态为运行中
|
||||
# 查找已存在的任务记录
|
||||
db_task = db.query(ScanTask).filter(ScanTask.celery_task_id == task_id).first()
|
||||
if not db_task:
|
||||
db_task = ScanService.create_scan_task(db, 'ping', network_id)
|
||||
db_task.celery_task_id = task_id
|
||||
db.commit()
|
||||
db.refresh(db_task)
|
||||
|
||||
ScanService.update_task_status(db, db_task.id, TaskStatus.RUNNING, progress=0)
|
||||
|
||||
# 执行扫描
|
||||
results = EnhancedScanService.scan_network(
|
||||
network.cidr,
|
||||
enable_ping=enable_ping,
|
||||
enable_arp=enable_arp,
|
||||
enable_dns=enable_dns,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# 批量更新IP信息
|
||||
stats = EnhancedScanService.bulk_update_ips_from_scan(db, results)
|
||||
|
||||
# 更新任务状态
|
||||
ScanService.update_task_status(
|
||||
db,
|
||||
db_task.id,
|
||||
TaskStatus.COMPLETED,
|
||||
progress=100,
|
||||
total_count=stats['total_scanned'],
|
||||
success_count=stats['online_count']
|
||||
)
|
||||
|
||||
logger.info(f"网段 {network.cidr} 扫描完成: 在线 {stats['online_count']} 台, "
|
||||
f"发现MAC {stats['mac_found_count']} 个, 主机名 {stats['hostname_found_count']} 个")
|
||||
|
||||
return {
|
||||
'status': 'completed',
|
||||
'network_id': network_id,
|
||||
'cidr': network.cidr,
|
||||
'statistics': stats
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"扫描失败: {str(e)}", exc_info=True)
|
||||
if 'db_task' in locals() and db_task:
|
||||
ScanService.update_task_status(
|
||||
db,
|
||||
db_task.id,
|
||||
TaskStatus.FAILED,
|
||||
error_message=str(e)
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def scan_single_ip_task(self, ip_address: str,
|
||||
enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True):
|
||||
"""
|
||||
Celery 异步任务:扫描单个IP
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = EnhancedScanService.comprehensive_scan(
|
||||
ip_address,
|
||||
enable_ping=enable_ping,
|
||||
enable_arp=enable_arp,
|
||||
enable_dns=enable_dns
|
||||
)
|
||||
|
||||
# 更新IP信息
|
||||
EnhancedScanService.update_ip_from_scan_result(db, result)
|
||||
db.commit()
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def full_network_scan(self, enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True):
|
||||
"""
|
||||
Celery 定时任务:扫描所有网段
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_, all_networks = NetworkService.get_list(db, limit=1000)
|
||||
|
||||
total_stats = {
|
||||
'total_networks': len(all_networks),
|
||||
'total_ips': 0,
|
||||
'online_count': 0,
|
||||
'mac_found_count': 0,
|
||||
'hostname_found_count': 0
|
||||
}
|
||||
|
||||
logger.info(f"开始全量扫描: {len(all_networks)} 个网段")
|
||||
|
||||
for network in all_networks:
|
||||
try:
|
||||
results = EnhancedScanService.scan_network(
|
||||
network.cidr,
|
||||
enable_ping=enable_ping,
|
||||
enable_arp=enable_arp,
|
||||
enable_dns=enable_dns
|
||||
)
|
||||
|
||||
stats = EnhancedScanService.bulk_update_ips_from_scan(db, results)
|
||||
|
||||
total_stats['total_ips'] += stats['total_scanned']
|
||||
total_stats['online_count'] += stats['online_count']
|
||||
total_stats['mac_found_count'] += stats['mac_found_count']
|
||||
total_stats['hostname_found_count'] += stats['hostname_found_count']
|
||||
|
||||
logger.info(f"网段 {network.cidr} 扫描完成: 在线 {stats['online_count']} 台")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"网段 {network.cidr} 扫描失败: {str(e)}")
|
||||
|
||||
logger.info(f"全量扫描完成: {total_stats}")
|
||||
|
||||
return {
|
||||
'status': 'completed',
|
||||
'statistics': total_stats
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def update_all_statistics(self):
|
||||
"""
|
||||
定时任务:更新所有网段统计信息
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
NetworkService.get_all_stats(db)
|
||||
logger.info("所有网段统计信息已更新")
|
||||
return {'status': 'completed'}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def quick_status_update(self, ip_addresses: List[str]):
|
||||
"""
|
||||
快速更新多个IP的状态(用于实时扫描)
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
results = []
|
||||
|
||||
for ip in ip_addresses:
|
||||
result = EnhancedScanService.comprehensive_scan(ip, enable_ping=True, enable_arp=True, enable_dns=False)
|
||||
EnhancedScanService.update_ip_from_scan_result(db, result)
|
||||
results.append(result)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'scanned_count': len(ip_addresses),
|
||||
'online_count': sum(1 for r in results if r.get('success')),
|
||||
'results': results
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# 返回任务函数供外部使用
|
||||
return {
|
||||
'scan_network_task': scan_network_task,
|
||||
'scan_single_ip_task': scan_single_ip_task,
|
||||
'full_network_scan': full_network_scan,
|
||||
'update_all_statistics': update_all_statistics,
|
||||
'quick_status_update': quick_status_update
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
fastapi==0.110.0
|
||||
uvicorn[standard]==0.27.1
|
||||
sqlalchemy==2.0.28
|
||||
pymysql==1.1.0
|
||||
cryptography==42.0.5
|
||||
pydantic==2.6.4
|
||||
pydantic-settings==2.2.1
|
||||
celery==5.3.6
|
||||
redis==5.0.3
|
||||
python-multipart==0.0.9
|
||||
alembic==1.13.1
|
||||
pysnmp==7.1.15
|
||||
scapy==2.5.0
|
||||
python-dotenv==1.0.1
|
||||
passlib[bcrypt]==1.7.4
|
||||
httpx==0.27.0
|
||||
@@ -0,0 +1,103 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# MySQL 数据库
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: ipam-mysql
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-ipam2024
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-ipam}
|
||||
MYSQL_USER: ${MYSQL_USER:-ipam}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-ipam2024}
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
|
||||
# Redis 缓存 & Celery Broker
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: ipam-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# FastAPI 后端服务
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: ipam-backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=mysql+pymysql://${MYSQL_USER:-ipam}:${MYSQL_PASSWORD:-ipam2024}@mysql:3306/${MYSQL_DATABASE:-ipam}
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# Celery Worker - 扫描任务执行
|
||||
celery-worker:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: ipam-celery-worker
|
||||
command: celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
|
||||
environment:
|
||||
- DATABASE_URL=mysql+pymysql://${MYSQL_USER:-ipam}:${MYSQL_PASSWORD:-ipam2024}@mysql:3306/${MYSQL_DATABASE:-ipam}
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# Celery Beat - 定时任务调度
|
||||
celery-beat:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: ipam-celery-beat
|
||||
command: celery -A app.tasks.celery_app beat --loglevel=info
|
||||
environment:
|
||||
- DATABASE_URL=mysql+pymysql://${MYSQL_USER:-ipam}:${MYSQL_PASSWORD:-ipam2024}@mysql:3306/${MYSQL_DATABASE:-ipam}
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
redis_data:
|
||||
@@ -0,0 +1,260 @@
|
||||
# IPAM 管理系统 - 开发文档
|
||||
|
||||
## 项目概述
|
||||
|
||||
IPAM (IP Address Management) 是一个企业级IP地址管理系统,用于自动化扫描、发现和管理网络IP资产。
|
||||
|
||||
## 需求优先级划分
|
||||
|
||||
### P0 - 核心功能(必须优先实现)
|
||||
|
||||
#### 1. 项目基础架构
|
||||
- [ ] FastAPI 后端框架搭建
|
||||
- [ ] MySQL 数据库模型设计
|
||||
- [ ] Redis 缓存配置
|
||||
- [ ] Celery 异步任务框架
|
||||
- [ ] Docker & Docker Compose 容器化部署
|
||||
|
||||
#### 2. 网络与子网管理
|
||||
- [ ] 网段CRUD(支持CIDR格式)
|
||||
- [ ] 网段分组管理
|
||||
- [ ] 网段统计视图(总IP、已用、空闲、使用率)
|
||||
|
||||
#### 3. IP资产台账
|
||||
- [ ] IP地址实体模型
|
||||
- [ ] 基础字段:IP、MAC、主机名
|
||||
- [ ] IP状态管理(在线、离线、空闲、保留)
|
||||
- [ ] 自定义字段扩展框架
|
||||
|
||||
#### 4. 基础扫描引擎
|
||||
- [ ] ICMP Ping扫描
|
||||
- [ ] 手动触发扫描
|
||||
|
||||
### P1 - 重要功能(核心功能完成后实现)
|
||||
|
||||
#### 1. 高级扫描功能
|
||||
- [ ] ARP扫描获取MAC地址
|
||||
- [ ] 反向DNS解析主机名
|
||||
- [ ] 定时扫描任务调度
|
||||
|
||||
#### 2. SNMP设备集成
|
||||
- [ ] SNMP凭据池(v2c + v3)
|
||||
- [ ] 网络设备台账
|
||||
- [ ] SNMP连通性测试
|
||||
- [ ] ARP表拉取(IP-MAC映射)
|
||||
- [ ] MAC地址表拉取(MAC-端口映射)
|
||||
|
||||
#### 3. 告警与异常检测
|
||||
- [ ] IP冲突告警
|
||||
- [ ] 非授权接入告警
|
||||
- [ ] 网段耗尽预警
|
||||
|
||||
### P2 - 增强功能
|
||||
|
||||
#### 1. 厂商深度发现
|
||||
- [ ] Cisco/H3C私有MIB支持
|
||||
- [ ] LLDP/CDP邻居发现
|
||||
- [ ] 物理拓扑解析
|
||||
- [ ] OUI厂商识别
|
||||
|
||||
#### 2. 系统管理
|
||||
- [ ] 操作审计日志
|
||||
- [ ] IP历史轨迹
|
||||
- [ ] 角色权限控制
|
||||
|
||||
#### 3. 前端界面
|
||||
- [ ] Vue 3 + Element Plus
|
||||
- [ ] 网段大盘视图
|
||||
- [ ] IP台账表格
|
||||
- [ ] 网络拓扑展示
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端
|
||||
- **Web框架**: FastAPI 0.110+
|
||||
- **数据库**: MySQL 8.0
|
||||
- **ORM**: SQLAlchemy 2.0
|
||||
- **缓存**: Redis 7.0
|
||||
- **异步任务**: Celery 5.3+
|
||||
- **SNMP**: pysnmp
|
||||
- **网络探测**: Scapy
|
||||
- **数据验证**: Pydantic 2.0
|
||||
|
||||
### 前端
|
||||
- **框架**: Vue 3
|
||||
- **UI组件**: Element Plus
|
||||
- **状态管理**: Pinia
|
||||
- **路由**: Vue Router 4
|
||||
|
||||
### 部署
|
||||
- **容器化**: Docker + Docker Compose
|
||||
- **反向代理**: Nginx
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
ipam/
|
||||
├── backend/ # 后端代码
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API路由
|
||||
│ │ │ ├── v1/
|
||||
│ │ │ │ ├── networks.py
|
||||
│ │ │ │ ├── ips.py
|
||||
│ │ │ │ ├── scan.py
|
||||
│ │ │ │ └── devices.py
|
||||
│ │ │ └── dependencies.py
|
||||
│ │ ├── core/ # 核心配置
|
||||
│ │ │ ├── config.py
|
||||
│ │ │ ├── database.py
|
||||
│ │ │ └── security.py
|
||||
│ │ ├── models/ # 数据库模型
|
||||
│ │ │ ├── network.py
|
||||
│ │ │ ├── ip.py
|
||||
│ │ │ ├── device.py
|
||||
│ │ │ └── alert.py
|
||||
│ │ ├── schemas/ # Pydantic模型
|
||||
│ │ │ ├── network.py
|
||||
│ │ │ ├── ip.py
|
||||
│ │ │ └── scan.py
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ │ ├── network_service.py
|
||||
│ │ │ ├── scan_service.py
|
||||
│ │ │ └── snmp_service.py
|
||||
│ │ ├── tasks/ # Celery任务
|
||||
│ │ │ ├── celery_app.py
|
||||
│ │ │ ├── scan_tasks.py
|
||||
│ │ │ └── snmp_tasks.py
|
||||
│ │ └── main.py
|
||||
│ ├── requirements.txt
|
||||
│ ├── Dockerfile
|
||||
│ └── .env.example
|
||||
├── frontend/ # 前端代码
|
||||
│ ├── src/
|
||||
│ ├── package.json
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── docs/ # 文档
|
||||
│ └── DEVELOPMENT.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 数据库设计
|
||||
|
||||
### 核心表结构
|
||||
|
||||
#### networks(网段表)
|
||||
```sql
|
||||
id INT PRIMARY KEY
|
||||
cidr VARCHAR(50) UNIQUE -- 如: 192.168.1.0/24
|
||||
name VARCHAR(100)
|
||||
description TEXT
|
||||
group_name VARCHAR(100) -- 分组名称
|
||||
gateway VARCHAR(50)
|
||||
vlan_id INT
|
||||
total_ips INT
|
||||
used_ips INT DEFAULT 0
|
||||
reserved_ips INT DEFAULT 0
|
||||
created_at DATETIME
|
||||
updated_at DATETIME
|
||||
```
|
||||
|
||||
#### ip_addresses(IP地址表)
|
||||
```sql
|
||||
id INT PRIMARY KEY
|
||||
network_id INT FOREIGN KEY
|
||||
ip_address VARCHAR(50) UNIQUE
|
||||
mac_address VARCHAR(50) NULL
|
||||
hostname VARCHAR(255) NULL
|
||||
status ENUM('available', 'online', 'offline', 'reserved')
|
||||
owner VARCHAR(100) NULL
|
||||
business_type VARCHAR(50) NULL
|
||||
notes TEXT NULL
|
||||
custom_fields JSON -- 自定义字段
|
||||
last_seen DATETIME NULL
|
||||
created_at DATETIME
|
||||
updated_at DATETIME
|
||||
```
|
||||
|
||||
#### scan_tasks(扫描任务表)
|
||||
```sql
|
||||
id INT PRIMARY KEY
|
||||
network_id INT NULL
|
||||
task_type ENUM('ping', 'arp', 'snmp')
|
||||
status ENUM('pending', 'running', 'completed', 'failed')
|
||||
progress INT DEFAULT 0
|
||||
started_at DATETIME NULL
|
||||
completed_at DATETIME NULL
|
||||
result TEXT NULL
|
||||
created_at DATETIME
|
||||
```
|
||||
|
||||
#### snmp_credentials(SNMP凭据表)
|
||||
```sql
|
||||
id INT PRIMARY KEY
|
||||
name VARCHAR(100)
|
||||
version ENUM('v2c', 'v3')
|
||||
community_string VARCHAR(255) NULL -- v2c
|
||||
username VARCHAR(100) NULL -- v3
|
||||
auth_password VARCHAR(255) NULL -- v3
|
||||
auth_protocol VARCHAR(50) NULL -- MD5, SHA
|
||||
priv_password VARCHAR(255) NULL -- v3
|
||||
priv_protocol VARCHAR(50) NULL -- DES, AES
|
||||
created_at DATETIME
|
||||
```
|
||||
|
||||
#### network_devices(网络设备表)
|
||||
```sql
|
||||
id INT PRIMARY KEY
|
||||
name VARCHAR(100)
|
||||
ip_address VARCHAR(50)
|
||||
device_type VARCHAR(50) -- core, distribution, access
|
||||
vendor VARCHAR(100) NULL
|
||||
model VARCHAR(100) NULL
|
||||
snmp_credential_id INT FOREIGN KEY
|
||||
last_polled_at DATETIME NULL
|
||||
created_at DATETIME
|
||||
```
|
||||
|
||||
#### alerts(告警表)
|
||||
```sql
|
||||
id INT PRIMARY KEY
|
||||
alert_type ENUM('ip_conflict', 'unauthorized', 'subnet_full')
|
||||
severity ENUM('warning', 'error', 'critical')
|
||||
message TEXT
|
||||
resolved BOOLEAN DEFAULT FALSE
|
||||
resolved_at DATETIME NULL
|
||||
created_at DATETIME
|
||||
```
|
||||
|
||||
## API 接口设计
|
||||
|
||||
### 网段管理
|
||||
- `GET /api/v1/networks` - 获取网段列表
|
||||
- `POST /api/v1/networks` - 创建网段
|
||||
- `GET /api/v1/networks/{id}` - 获取网段详情
|
||||
- `PUT /api/v1/networks/{id}` - 更新网段
|
||||
- `DELETE /api/v1/networks/{id}` - 删除网段
|
||||
- `GET /api/v1/networks/{id}/stats` - 获取网段统计数据
|
||||
|
||||
### IP地址管理
|
||||
- `GET /api/v1/ips` - 获取IP列表
|
||||
- `GET /api/v1/ips/{id}` - 获取IP详情
|
||||
- `PUT /api/v1/ips/{id}` - 更新IP信息
|
||||
- `GET /api/v1/ips/export` - 导出IP台账
|
||||
|
||||
### 扫描管理
|
||||
- `POST /api/v1/scan/ping/{network_id}` - 触发Ping扫描
|
||||
- `GET /api/v1/scan/tasks/{task_id}` - 获取扫描任务状态
|
||||
|
||||
## 开发规范
|
||||
|
||||
1. **代码风格**: 遵循 PEP 8,使用 black 格式化
|
||||
2. **类型注解**: 使用 Python 类型注解
|
||||
3. **数据库迁移**: 使用 Alembic 管理数据库迁移
|
||||
4. **API文档**: FastAPI 自动生成 Swagger 文档
|
||||
5. **日志**: 使用 logging 模块记录关键操作
|
||||
6. **测试**: 每个模块完成后编写单元测试
|
||||
|
||||
## 部署说明
|
||||
|
||||
详见 `docker-compose.yml` 配置文件。
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1839
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.18.1",
|
||||
"element-plus": "^2.14.3",
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
import api from '@/utils/api'
|
||||
import { authStore } from '@/utils/auth'
|
||||
|
||||
// 登录用 form-data(OAuth2PasswordRequestForm 期望 form-data)
|
||||
function login({ username, password }) {
|
||||
const formBody = new URLSearchParams()
|
||||
formBody.append('username', username)
|
||||
formBody.append('password', password)
|
||||
formBody.append('grant_type', 'password')
|
||||
return api.post('/auth/login', formBody, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
// 登录请求不能带 Authorization header(utils/api.js 里在已有 token 时会带)
|
||||
// 但登录接口允许带,下游忽略即可
|
||||
})
|
||||
}
|
||||
|
||||
async function loginAndStore({ username, password }) {
|
||||
const data = await login({ username, password })
|
||||
if (data?.access_token) {
|
||||
authStore.setAccessToken(data.access_token)
|
||||
authStore.setRefreshToken(data.refresh_token)
|
||||
authStore.setUserInfo(data.user_info)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function logout() {
|
||||
const refreshToken = authStore.getRefreshToken()
|
||||
authStore.clear()
|
||||
if (refreshToken) {
|
||||
// 后端撤销 refresh token(best-effort,不 await)
|
||||
try {
|
||||
const formBody = new URLSearchParams()
|
||||
formBody.append('refresh_token', refreshToken)
|
||||
api.post('/auth/logout', formBody, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
}).catch(() => {})
|
||||
} catch (_) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
function fetchCurrentUser() {
|
||||
return api.get('/auth/me').then(info => {
|
||||
authStore.setUserInfo(info)
|
||||
return info
|
||||
})
|
||||
}
|
||||
|
||||
function changePassword(oldPassword, newPassword) {
|
||||
return api.post('/auth/change-password', null, {
|
||||
params: { old_password: oldPassword, new_password: newPassword }
|
||||
})
|
||||
}
|
||||
|
||||
// 用户管理(admin 权限)
|
||||
// 注意:后端 endpoint 使用 query 参数接收(不是 JSON body),
|
||||
// 与其他 CRUD 接口的 POST + JSON body 风格不一致,但保持后端原样
|
||||
const userApi = {
|
||||
getList(params) {
|
||||
return api.get('/auth/users', { params })
|
||||
},
|
||||
getById(id) {
|
||||
return api.get(`/auth/users/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/auth/users', null, { params: data })
|
||||
},
|
||||
updateStatus(id, status) {
|
||||
return api.put(`/auth/users/${id}/status`, null, { params: { status } })
|
||||
},
|
||||
unlock(id) {
|
||||
return api.post(`/auth/users/${id}/unlock`)
|
||||
},
|
||||
resetPassword(id, newPassword) {
|
||||
return api.post(`/auth/users/${id}/reset-password`, null, { params: { new_password: newPassword } })
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/auth/users/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
export { login, loginAndStore, logout, fetchCurrentUser, changePassword, userApi }
|
||||
@@ -0,0 +1,176 @@
|
||||
import api from '@/utils/api'
|
||||
|
||||
// 网段管理
|
||||
export const networkApi = {
|
||||
// 获取网段列表
|
||||
getList(params) {
|
||||
return api.get('/networks', { params })
|
||||
},
|
||||
// 获取网段详情
|
||||
getById(id) {
|
||||
return api.get(`/networks/${id}`)
|
||||
},
|
||||
// 获取网段统计
|
||||
getStats(id) {
|
||||
return api.get(`/networks/${id}/stats`)
|
||||
},
|
||||
// 获取所有网段统计
|
||||
getAllStats() {
|
||||
return api.get('/networks/stats')
|
||||
},
|
||||
// 创建网段
|
||||
create(data) {
|
||||
return api.post('/networks', data)
|
||||
},
|
||||
// 更新网段
|
||||
update(id, data) {
|
||||
return api.put(`/networks/${id}`, data)
|
||||
},
|
||||
// 删除网段
|
||||
delete(id) {
|
||||
return api.delete(`/networks/${id}`)
|
||||
},
|
||||
// 获取网段下的IP
|
||||
getIPs(id, params) {
|
||||
return api.get(`/networks/${id}/ips`, { params })
|
||||
}
|
||||
}
|
||||
|
||||
// IP地址管理
|
||||
export const ipApi = {
|
||||
// 获取IP列表
|
||||
getList(params) {
|
||||
return api.get('/ips', { params })
|
||||
},
|
||||
// 获取IP详情
|
||||
getById(id) {
|
||||
return api.get(`/ips/${id}`)
|
||||
},
|
||||
// 更新IP信息
|
||||
update(id, data) {
|
||||
return api.put(`/ips/${id}`, data)
|
||||
},
|
||||
// 根据IP地址查询
|
||||
getByIP(ip) {
|
||||
return api.get(`/ips/address/${ip}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描管理
|
||||
export const scanApi = {
|
||||
// Ping扫描网段
|
||||
pingNetwork(id) {
|
||||
return api.post(`/scan/ping/${id}`)
|
||||
},
|
||||
// 综合扫描网段
|
||||
// options: { enable_ping, enable_arp, enable_dns, timeout }
|
||||
// 单次调用覆盖全局 30s 超时,因为 /24 网段扫描要数分钟
|
||||
comprehensiveScan(id, options = {}) {
|
||||
const {
|
||||
enable_ping = true,
|
||||
enable_arp = true,
|
||||
enable_dns = true,
|
||||
timeout = 2
|
||||
} = options
|
||||
return api.post(`/scan/comprehensive/${id}`, {}, {
|
||||
params: { enable_ping, enable_arp, enable_dns, timeout },
|
||||
timeout: 600000 // 10 分钟,足以应付 /16 级别网段
|
||||
})
|
||||
},
|
||||
// 单个IP扫描
|
||||
scanIP(ipAddress) {
|
||||
return api.post(`/scan/comprehensive/ip/${ipAddress}`)
|
||||
},
|
||||
// 获取统计摘要
|
||||
getSummary() {
|
||||
return api.get('/scan/statistics/summary')
|
||||
}
|
||||
}
|
||||
|
||||
// 告警管理
|
||||
export const alertApi = {
|
||||
// 获取告警列表
|
||||
getList(params) {
|
||||
return api.get('/alerts', { params })
|
||||
},
|
||||
// 获取告警统计
|
||||
getStats() {
|
||||
return api.get('/alerts/statistics/summary')
|
||||
},
|
||||
// 运行所有检测
|
||||
runDetection() {
|
||||
return api.post('/alerts/detect/run')
|
||||
},
|
||||
// 确认告警
|
||||
acknowledge(id) {
|
||||
return api.post(`/alerts/${id}/acknowledge`)
|
||||
},
|
||||
// 解决告警
|
||||
resolve(id, notes) {
|
||||
return api.post(`/alerts/${id}/resolve`, {}, { params: { notes } })
|
||||
},
|
||||
// 忽略告警
|
||||
ignore(id) {
|
||||
return api.post(`/alerts/${id}/ignore`)
|
||||
},
|
||||
// MAC白名单
|
||||
getWhitelist(params) {
|
||||
return api.get('/alerts/whitelist/macs', { params })
|
||||
},
|
||||
addToWhitelist(data) {
|
||||
return api.post('/alerts/whitelist/macs', {}, { params: data })
|
||||
},
|
||||
removeFromWhitelist(id) {
|
||||
return api.delete(`/alerts/whitelist/macs/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// SNMP管理
|
||||
export const snmpApi = {
|
||||
// 凭据管理
|
||||
getCredentials() {
|
||||
return api.get('/snmp/credentials')
|
||||
},
|
||||
createCredential(data) {
|
||||
return api.post('/snmp/credentials', {}, { params: data })
|
||||
},
|
||||
updateCredential(id, data) {
|
||||
return api.put(`/snmp/credentials/${id}`, {}, { params: data })
|
||||
},
|
||||
deleteCredential(id) {
|
||||
return api.delete(`/snmp/credentials/${id}`)
|
||||
},
|
||||
|
||||
// 设备管理
|
||||
getDevices(params) {
|
||||
return api.get('/snmp/devices', { params })
|
||||
},
|
||||
createDevice(data) {
|
||||
return api.post('/snmp/devices', {}, { params: data })
|
||||
},
|
||||
updateDevice(id, data) {
|
||||
return api.put(`/snmp/devices/${id}`, {}, { params: data })
|
||||
},
|
||||
deleteDevice(id) {
|
||||
return api.delete(`/snmp/devices/${id}`)
|
||||
},
|
||||
|
||||
// SNMP操作
|
||||
testConnection(id) {
|
||||
return api.post(`/snmp/devices/${id}/test`)
|
||||
},
|
||||
pollDevice(id) {
|
||||
return api.post(`/snmp/devices/${id}/poll`)
|
||||
},
|
||||
getARPTable(id) {
|
||||
return api.post(`/snmp/devices/${id}/arp`)
|
||||
},
|
||||
getInterfaces(id) {
|
||||
return api.post(`/snmp/devices/${id}/interfaces`)
|
||||
},
|
||||
|
||||
// 获取统计
|
||||
getStats() {
|
||||
return api.get('/snmp/statistics/summary')
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="isCollapse ? '64px' : '220px'" class="sidebar">
|
||||
<div class="logo">
|
||||
<el-icon :size="32" color="#409eff">
|
||||
<Monitor />
|
||||
</el-icon>
|
||||
<span v-if="!isCollapse" class="logo-text">IPAM管理系统</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="isCollapse"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
background-color="#304156"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409eff"
|
||||
class="sidebar-menu"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<template #title>仪表盘</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/networks">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<template #title>网段管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/ips">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<template #title>IP地址</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/scan">
|
||||
<el-icon><Search /></el-icon>
|
||||
<template #title>扫描管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/snmp">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<template #title>SNMP管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/alerts">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<template #title>告警中心</template>
|
||||
<el-badge v-if="alertCount > 0" :value="alertCount" class="menu-badge" />
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="isAdmin" index="/users">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<template #title>用户管理</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-container class="main-container">
|
||||
<!-- 顶部导航 -->
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-icon class="collapse-btn" @click="toggleCollapse">
|
||||
<Fold v-if="!isCollapse" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ currentPageTitle }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-tooltip content="刷新数据" placement="bottom">
|
||||
<el-icon class="header-icon" @click="refreshData">
|
||||
<RefreshRight />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-dropdown trigger="click" @command="onUserMenuCommand">
|
||||
<span class="user-info">
|
||||
<el-icon :size="18"><User /></el-icon>
|
||||
<span>{{ displayName }}</span>
|
||||
<el-tag v-if="userInfo" size="small" :type="roleTagType" effect="plain" class="role-tag">
|
||||
{{ roleLabel }}
|
||||
</el-tag>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><UserFilled /></el-icon> 个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isAdmin" command="users" divided>
|
||||
<el-icon><Setting /></el-icon> 用户管理
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>
|
||||
<el-icon><SwitchButton /></el-icon> 退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<el-main class="main-content">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { alertApi } from '@/api'
|
||||
import { authStore } from '@/utils/auth'
|
||||
import { logout as doLogout } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const isCollapse = ref(false)
|
||||
const alertCount = ref(0)
|
||||
const userInfo = ref(authStore.getUserInfo())
|
||||
|
||||
const isAdmin = computed(() => authStore.hasRole('super_admin', 'admin'))
|
||||
|
||||
const displayName = computed(() => {
|
||||
const u = userInfo.value
|
||||
if (!u) return '未登录'
|
||||
return u.real_name || u.username || '用户'
|
||||
})
|
||||
|
||||
const roleLabel = computed(() => {
|
||||
const map = {
|
||||
super_admin: '超级管理员',
|
||||
admin: '管理员',
|
||||
operator: '操作员',
|
||||
viewer: '访客',
|
||||
}
|
||||
return map[userInfo.value?.role] || '用户'
|
||||
})
|
||||
|
||||
const roleTagType = computed(() => {
|
||||
const map = {
|
||||
super_admin: 'danger',
|
||||
admin: 'warning',
|
||||
operator: 'success',
|
||||
viewer: 'info',
|
||||
}
|
||||
return map[userInfo.value?.role] || ''
|
||||
})
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
const currentPageTitle = computed(() => {
|
||||
return route.meta.title || 'IPAM管理系统'
|
||||
})
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapse.value = !isCollapse.value
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
router.go(0)
|
||||
}
|
||||
|
||||
async function onUserMenuCommand(cmd) {
|
||||
if (cmd === 'profile') {
|
||||
router.push('/profile')
|
||||
} else if (cmd === 'users') {
|
||||
router.push('/users')
|
||||
} else if (cmd === 'logout') {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '退出',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch (_) {
|
||||
return
|
||||
}
|
||||
doLogout()
|
||||
ElMessage.success('已退出登录')
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const loadAlertCount = async () => {
|
||||
try {
|
||||
const stats = await alertApi.getStats()
|
||||
alertCount.value = stats.by_status?.active || 0
|
||||
} catch (error) {
|
||||
// 401 已在 axios 拦截器处理,这里静默
|
||||
console.error('加载告警数量失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAlertCount()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100vh;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: #304156;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #1f2d3d;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.menu-badge {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.header-icon:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(pinia)
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { authStore } from '@/utils/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { title: '登录', public: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layout/MainLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: '仪表盘' }
|
||||
},
|
||||
{
|
||||
path: 'networks',
|
||||
name: 'Networks',
|
||||
component: () => import('@/views/Networks.vue'),
|
||||
meta: { title: '网段管理' }
|
||||
},
|
||||
{
|
||||
path: 'ips',
|
||||
name: 'IPs',
|
||||
component: () => import('@/views/IPs.vue'),
|
||||
meta: { title: 'IP地址' }
|
||||
},
|
||||
{
|
||||
path: 'scan',
|
||||
name: 'Scan',
|
||||
component: () => import('@/views/Scan.vue'),
|
||||
meta: { title: '扫描管理' }
|
||||
},
|
||||
{
|
||||
path: 'snmp',
|
||||
name: 'SNMP',
|
||||
component: () => import('@/views/SNMP.vue'),
|
||||
meta: { title: 'SNMP管理' }
|
||||
},
|
||||
{
|
||||
path: 'alerts',
|
||||
name: 'Alerts',
|
||||
component: () => import('@/views/Alerts.vue'),
|
||||
meta: { title: '告警中心' }
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'Users',
|
||||
component: () => import('@/views/Users.vue'),
|
||||
meta: { title: '用户管理', requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/Profile.vue'),
|
||||
meta: { title: '个人中心' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { public: true }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 全局路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 公开路由(登录页)直接放行
|
||||
if (to.meta?.public) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
// 未登录跳登录
|
||||
if (!authStore.isLoggedIn()) {
|
||||
next({ path: '/login', query: { next: to.fullPath } })
|
||||
return
|
||||
}
|
||||
// 需要管理员
|
||||
if (to.meta?.requiresAdmin && !authStore.hasRole('super_admin', 'admin')) {
|
||||
next({ path: '/dashboard' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
if (to.meta?.title) {
|
||||
document.title = `${to.meta.title} - IPAM管理系统`
|
||||
} else {
|
||||
document.title = 'IPAM管理系统'
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { authStore } from '@/utils/auth'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器:自动带 Authorization header
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
const token = authStore.getAccessToken()
|
||||
if (token) {
|
||||
config.headers = config.headers || {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 标记正在刷新 token,防止并发 401 触发多次刷新
|
||||
let isRefreshing = false
|
||||
let pendingQueue = []
|
||||
|
||||
function flushQueue(newToken) {
|
||||
pendingQueue.forEach(cb => cb(newToken))
|
||||
pendingQueue = []
|
||||
}
|
||||
|
||||
function queueRefresh(cb) {
|
||||
pendingQueue.push(cb)
|
||||
}
|
||||
|
||||
// 响应拦截器:401 时尝试 refresh token;refresh 失败就跳登录
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
return response.data
|
||||
},
|
||||
async error => {
|
||||
const originalRequest = error.config || {}
|
||||
const status = error.response?.status
|
||||
const detail = error.response?.data?.detail
|
||||
|
||||
// 401 且不是 login/refresh 本身:尝试刷新 token
|
||||
if (status === 401 && !originalRequest._retry && !originalRequest.url?.includes('/auth/')) {
|
||||
const refreshToken = authStore.getRefreshToken()
|
||||
if (!refreshToken) {
|
||||
authStore.clear()
|
||||
redirectToLogin()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
// 已有刷新请求在进行,等它完成后再重试
|
||||
return new Promise(resolve => {
|
||||
queueRefresh(newToken => {
|
||||
if (newToken) {
|
||||
originalRequest._retry = true
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
resolve(api(originalRequest))
|
||||
} else {
|
||||
redirectToLogin()
|
||||
resolve(Promise.reject(error))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
try {
|
||||
const formBody = new URLSearchParams()
|
||||
formBody.append('refresh_token', refreshToken)
|
||||
const resp = await axios.post('/api/v1/auth/refresh', formBody, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
})
|
||||
const newToken = resp.data?.access_token
|
||||
if (newToken) {
|
||||
authStore.setAccessToken(newToken)
|
||||
if (resp.data?.refresh_token) {
|
||||
authStore.setRefreshToken(resp.data.refresh_token)
|
||||
}
|
||||
isRefreshing = false
|
||||
flushQueue(newToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return api(originalRequest)
|
||||
} else {
|
||||
throw new Error('refresh 响应缺少 access_token')
|
||||
}
|
||||
} catch (refreshErr) {
|
||||
isRefreshing = false
|
||||
flushQueue(null)
|
||||
authStore.clear()
|
||||
redirectToLogin()
|
||||
return Promise.reject(refreshErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 非 401 或 refresh 后仍失败的:弹 toast(保持原行为,避免 view 重复弹)
|
||||
console.error('API Error:', error)
|
||||
ElMessage.error(typeof detail === 'string' ? detail : '请求失败')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
function redirectToLogin() {
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
const next = encodeURIComponent(window.location.pathname + window.location.search)
|
||||
window.location.href = `/login?next=${next}`
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,56 @@
|
||||
// token / 用户信息本地存储工具
|
||||
const ACCESS_TOKEN_KEY = 'ipam_access_token'
|
||||
const REFRESH_TOKEN_KEY = 'ipam_refresh_token'
|
||||
const USER_INFO_KEY = 'ipam_user_info'
|
||||
|
||||
export const authStore = {
|
||||
getAccessToken() {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY) || ''
|
||||
},
|
||||
setAccessToken(token) {
|
||||
if (token) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, token)
|
||||
} else {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
}
|
||||
},
|
||||
getRefreshToken() {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
||||
},
|
||||
setRefreshToken(token) {
|
||||
if (token) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, token)
|
||||
} else {
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
},
|
||||
getUserInfo() {
|
||||
const raw = localStorage.getItem(USER_INFO_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
setUserInfo(info) {
|
||||
if (info) {
|
||||
localStorage.setItem(USER_INFO_KEY, JSON.stringify(info))
|
||||
} else {
|
||||
localStorage.removeItem(USER_INFO_KEY)
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
localStorage.removeItem(USER_INFO_KEY)
|
||||
},
|
||||
isLoggedIn() {
|
||||
return !!this.getAccessToken()
|
||||
},
|
||||
// 基于 role 判断(仅前端展示用,真实校验在后端)
|
||||
hasRole(...roles) {
|
||||
const u = this.getUserInfo()
|
||||
return u && roles.includes(u.role)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
<template>
|
||||
<div class="alerts-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<span>告警中心</span>
|
||||
<el-button-group>
|
||||
<el-button type="danger" @click="runDetection" :loading="detecting">
|
||||
<el-icon><Search /></el-icon>
|
||||
立即检测
|
||||
</el-button>
|
||||
<el-button @click="acknowledgeAll">
|
||||
<el-icon><Check /></el-icon>
|
||||
全部确认
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 告警统计 -->
|
||||
<el-row :gutter="20" style="margin-bottom: 20px;">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card active">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="32"><WarningFilled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.by_status?.active || 0 }}</div>
|
||||
<div class="stat-label">活跃告警</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card critical">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="32"><CircleCloseFilled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.by_severity?.critical || 0 }}</div>
|
||||
<div class="stat-label">严重告警</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card error">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="32"><CircleClose /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.by_severity?.error || 0 }}</div>
|
||||
<div class="stat-label">错误告警</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card warning">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="32"><InfoFilled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.by_severity?.warning || 0 }}</div>
|
||||
<div class="stat-label">警告告警</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="选择状态" clearable @change="loadAlerts">
|
||||
<el-option label="活跃" value="active" />
|
||||
<el-option label="已确认" value="acknowledged" />
|
||||
<el-option label="已解决" value="resolved" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="级别">
|
||||
<el-select v-model="filters.severity" placeholder="选择级别" clearable @change="loadAlerts">
|
||||
<el-option label="严重" value="critical" />
|
||||
<el-option label="错误" value="error" />
|
||||
<el-option label="警告" value="warning" />
|
||||
<el-option label="信息" value="info" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="filters.alertType" placeholder="选择类型" clearable @change="loadAlerts">
|
||||
<el-option label="IP冲突" value="ip_conflict" />
|
||||
<el-option label="未授权接入" value="unauthorized_access" />
|
||||
<el-option label="网段耗尽" value="subnet_full" />
|
||||
<el-option label="设备离线" value="device_offline" />
|
||||
<el-option label="新设备发现" value="new_device_detected" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadAlerts">
|
||||
<el-icon><Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="resetFilters">
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 告警表格 -->
|
||||
<el-table :data="tableData" style="width: 100%" v-loading="loading" stripe>
|
||||
<el-table-column label="级别" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getSeverityType(scope.row.severity)" size="small">
|
||||
{{ getSeverityText(scope.row.severity) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="120">
|
||||
<template #default="scope">
|
||||
{{ getAlertTypeText(scope.row.alert_type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="message" label="详情" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="ip_address_str" label="相关IP" width="140">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.ip_address_str" class="ip-address">{{ scope.row.ip_address_str }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mac_address" label="MAC地址" width="160">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.mac_address" class="mac-address">{{ scope.row.mac_address }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-if="scope.row.status === 'active'" size="small" type="primary" @click="acknowledge(scope.row.id)">
|
||||
<el-icon><Check /></el-icon>
|
||||
确认
|
||||
</el-button>
|
||||
<el-button v-if="scope.row.status !== 'resolved'" size="small" type="success" @click="resolve(scope.row.id)">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
解决
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[20, 50, 100, 200]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadAlerts"
|
||||
@current-change="loadAlerts"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- MAC白名单管理 -->
|
||||
<el-card style="margin-top: 20px;">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<span>MAC地址白名单</span>
|
||||
<el-button type="primary" @click="showWhitelistDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加白名单
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="whitelist" style="width: 100%" v-loading="whitelistLoading" stripe>
|
||||
<el-table-column prop="mac_address" label="MAC地址" width="180">
|
||||
<template #default="scope">
|
||||
<span class="mac-address">{{ scope.row.mac_address }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="owner" label="所有者" width="120" />
|
||||
<el-table-column prop="created_at" label="添加时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="danger" @click="removeFromWhitelist(scope.row.id)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加白名单对话框 -->
|
||||
<el-dialog
|
||||
v-model="whitelistDialogVisible"
|
||||
title="添加MAC白名单"
|
||||
width="500px"
|
||||
@close="resetWhitelistForm"
|
||||
>
|
||||
<el-form ref="whitelistFormRef" :model="whitelistForm" label-width="100px">
|
||||
<el-form-item label="MAC地址" prop="mac_address">
|
||||
<el-input v-model="whitelistForm.mac_address" placeholder="例如: 00:1A:2B:3C:4D:5E" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="whitelistForm.description" placeholder="设备描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所有者">
|
||||
<el-input v-model="whitelistForm.owner" placeholder="设备所有者" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="whitelistDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="addToWhitelist" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { alertApi } from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const detecting = ref(false)
|
||||
const whitelistLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const tableData = ref([])
|
||||
const stats = ref({
|
||||
by_status: { active: 0, acknowledged: 0, resolved: 0 },
|
||||
by_severity: { critical: 0, error: 0, warning: 0, info: 0 },
|
||||
by_type: {}
|
||||
})
|
||||
const whitelist = ref([])
|
||||
|
||||
const filters = reactive({
|
||||
status: 'active',
|
||||
severity: '',
|
||||
alertType: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const whitelistDialogVisible = ref(false)
|
||||
const whitelistFormRef = ref(null)
|
||||
const whitelistForm = reactive({
|
||||
mac_address: '',
|
||||
description: '',
|
||||
owner: ''
|
||||
})
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
stats.value = await alertApi.getStats()
|
||||
} catch (error) {
|
||||
console.error('加载统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAlerts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
skip: (pagination.page - 1) * pagination.limit,
|
||||
limit: pagination.limit
|
||||
}
|
||||
if (filters.status) params.status = filters.status
|
||||
if (filters.severity) params.severity = filters.severity
|
||||
if (filters.alertType) params.alertType = filters.alertType
|
||||
|
||||
const result = await alertApi.getList(params)
|
||||
tableData.value = result.items
|
||||
pagination.total = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载告警列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.status = 'active'
|
||||
filters.severity = ''
|
||||
filters.alertType = ''
|
||||
pagination.page = 1
|
||||
loadAlerts()
|
||||
}
|
||||
|
||||
const runDetection = async () => {
|
||||
detecting.value = true
|
||||
try {
|
||||
await alertApi.runDetection()
|
||||
ElMessage.success('检测完成')
|
||||
loadAlerts()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
ElMessage.error('检测失败')
|
||||
} finally {
|
||||
detecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const acknowledge = async (id) => {
|
||||
try {
|
||||
await alertApi.acknowledge(id)
|
||||
ElMessage.success('已确认')
|
||||
loadAlerts()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const resolve = async (id) => {
|
||||
try {
|
||||
await alertApi.resolve(id, '')
|
||||
ElMessage.success('已标记为解决')
|
||||
loadAlerts()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const acknowledgeAll = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认将所有活跃告警标记为已确认?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
// 这里可以批量确认
|
||||
ElMessage.success('操作成功')
|
||||
loadAlerts()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 白名单相关
|
||||
const loadWhitelist = async () => {
|
||||
whitelistLoading.value = true
|
||||
try {
|
||||
const result = await alertApi.getWhitelist()
|
||||
whitelist.value = result.items
|
||||
} catch (error) {
|
||||
console.error('加载白名单失败:', error)
|
||||
} finally {
|
||||
whitelistLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showWhitelistDialog = () => {
|
||||
resetWhitelistForm()
|
||||
whitelistDialogVisible.value = true
|
||||
}
|
||||
|
||||
const resetWhitelistForm = () => {
|
||||
whitelistForm.mac_address = ''
|
||||
whitelistForm.description = ''
|
||||
whitelistForm.owner = ''
|
||||
whitelistFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const addToWhitelist = async () => {
|
||||
if (!whitelistForm.mac_address) {
|
||||
ElMessage.warning('请输入MAC地址')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await alertApi.addToWhitelist({
|
||||
mac_address: whitelistForm.mac_address,
|
||||
description: whitelistForm.description,
|
||||
owner: whitelistForm.owner
|
||||
})
|
||||
ElMessage.success('添加成功')
|
||||
whitelistDialogVisible.value = false
|
||||
loadWhitelist()
|
||||
} catch (error) {
|
||||
ElMessage.error('添加失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromWhitelist = async (id) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要从白名单中移除该MAC吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await alertApi.removeFromWhitelist(id)
|
||||
ElMessage.success('已移除')
|
||||
loadWhitelist()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityType = (severity) => {
|
||||
const types = {
|
||||
critical: 'danger',
|
||||
error: 'danger',
|
||||
warning: 'warning',
|
||||
info: 'info'
|
||||
}
|
||||
return types[severity] || 'info'
|
||||
}
|
||||
|
||||
const getSeverityText = (severity) => {
|
||||
const texts = {
|
||||
critical: '严重',
|
||||
error: '错误',
|
||||
warning: '警告',
|
||||
info: '信息'
|
||||
}
|
||||
return texts[severity] || severity
|
||||
}
|
||||
|
||||
const getAlertTypeText = (type) => {
|
||||
const texts = {
|
||||
ip_conflict: 'IP冲突',
|
||||
unauthorized_access: '未授权接入',
|
||||
subnet_full: '网段耗尽',
|
||||
scan_failed: '扫描失败',
|
||||
device_offline: '设备离线',
|
||||
new_device_detected: '新设备发现',
|
||||
mac_changed: 'MAC变更'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadAlerts()
|
||||
loadWhitelist()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ip-address {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 500;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.mac-address {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-card.active {
|
||||
border-left: 4px solid #409eff;
|
||||
}
|
||||
|
||||
.stat-card.critical {
|
||||
border-left: 4px solid #f56c6c;
|
||||
}
|
||||
|
||||
.stat-card.error {
|
||||
border-left: 4px solid #e6a23c;
|
||||
}
|
||||
|
||||
.stat-card.warning {
|
||||
border-left: 4px solid #67c23a;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-card.active .stat-icon {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-card.critical .stat-icon {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-card.error .stat-icon {
|
||||
background: linear-gradient(135deg, #f5576c 0%, #f093fb 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-card.warning .stat-icon {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card stat-total">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="32"><Connection /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.totalNetworks }}</div>
|
||||
<div class="stat-label">网段总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card stat-ip">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="32"><Cpu /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.totalIPs }}</div>
|
||||
<div class="stat-label">IP地址总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card stat-online">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="32"><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.onlineIPs }}</div>
|
||||
<div class="stat-label">在线设备</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card stat-alert">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<el-icon :size="32"><WarningFilled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.activeAlerts }}</div>
|
||||
<div class="stat-label">活跃告警</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 网段使用率图表 -->
|
||||
<el-row :gutter="20" style="margin-top: 20px;">
|
||||
<el-col :span="16">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>网段使用率</span>
|
||||
<el-button type="primary" size="small" @click="loadNetworkStats">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="networkStats" style="width: 100%">
|
||||
<el-table-column prop="cidr" label="网段" width="140" />
|
||||
<el-table-column prop="name" label="名称" width="150" />
|
||||
<el-table-column prop="total_ips" label="总IP数" width="100" align="center" />
|
||||
<el-table-column prop="used_ips" label="已用" width="80" align="center" />
|
||||
<el-table-column prop="reserved_ips" label="保留" width="80" align="center" />
|
||||
<el-table-column prop="available_ips" label="可用" width="80" align="center" />
|
||||
<el-table-column label="使用率" width="250">
|
||||
<template #default="scope">
|
||||
<el-progress
|
||||
:percentage="scope.row.usage_percent"
|
||||
:color="getProgressColor(scope.row.usage_percent)"
|
||||
:stroke-width="16"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="primary" @click="goToNetwork(scope.row.id)">
|
||||
查看
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 告警统计 -->
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>告警统计</span>
|
||||
<el-button type="primary" size="small" @click="runDetection">
|
||||
<el-icon><Search /></el-icon>
|
||||
检测
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="alert-stats">
|
||||
<div class="alert-item critical">
|
||||
<span class="alert-count">{{ alertStats.by_severity.critical }}</span>
|
||||
<span class="alert-label">严重告警</span>
|
||||
</div>
|
||||
<div class="alert-item error">
|
||||
<span class="alert-count">{{ alertStats.by_severity.error }}</span>
|
||||
<span class="alert-label">错误</span>
|
||||
</div>
|
||||
<div class="alert-item warning">
|
||||
<span class="alert-count">{{ alertStats.by_severity.warning }}</span>
|
||||
<span class="alert-label">警告</span>
|
||||
</div>
|
||||
<div class="alert-item info">
|
||||
<span class="alert-count">{{ alertStats.by_severity.info }}</span>
|
||||
<span class="alert-label">信息</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="alert-types">
|
||||
<div v-for="(count, type) in alertStats.by_type" :key="type" class="alert-type-item">
|
||||
<span class="type-name">{{ getAlertTypeName(type) }}</span>
|
||||
<el-tag :type="count > 0 ? 'danger' : 'info'" size="small">{{ count }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<el-row :gutter="20" style="margin-top: 20px;">
|
||||
<el-col :span="24">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>快捷操作</span>
|
||||
</template>
|
||||
<el-space wrap>
|
||||
<el-button type="primary" @click="$router.push('/networks')">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加网段
|
||||
</el-button>
|
||||
<el-button type="success" @click="quickScanAll">
|
||||
<el-icon><Search /></el-icon>
|
||||
全量扫描
|
||||
</el-button>
|
||||
<el-button type="warning" @click="$router.push('/alerts')">
|
||||
<el-icon><Warning /></el-icon>
|
||||
查看告警
|
||||
</el-button>
|
||||
<el-button type="info" @click="$router.push('/snmp')">
|
||||
<el-icon><Setting /></el-icon>
|
||||
SNMP管理
|
||||
</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { networkApi, alertApi, scanApi } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const stats = ref({
|
||||
totalNetworks: 0,
|
||||
totalIPs: 0,
|
||||
onlineIPs: 0,
|
||||
activeAlerts: 0
|
||||
})
|
||||
|
||||
const networkStats = ref([])
|
||||
const alertStats = ref({
|
||||
by_status: { active: 0, acknowledged: 0, resolved: 0 },
|
||||
by_severity: { critical: 0, error: 0, warning: 0, info: 0 },
|
||||
by_type: {}
|
||||
})
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const [statsData, alertStatsData] = await Promise.all([
|
||||
scanApi.getSummary(),
|
||||
alertApi.getStats()
|
||||
])
|
||||
stats.value.totalIPs = statsData.ip_statistics.total
|
||||
stats.value.onlineIPs = statsData.ip_statistics.online
|
||||
alertStats.value = alertStatsData
|
||||
stats.value.activeAlerts = alertStatsData.by_status.active
|
||||
} catch (error) {
|
||||
console.error('加载统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadNetworkStats = async () => {
|
||||
try {
|
||||
networkStats.value = await networkApi.getAllStats()
|
||||
stats.value.totalNetworks = networkStats.value.length
|
||||
} catch (error) {
|
||||
console.error('加载网段统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const runDetection = async () => {
|
||||
try {
|
||||
await alertApi.runDetection()
|
||||
ElMessage.success('检测完成')
|
||||
await loadStats()
|
||||
} catch (error) {
|
||||
ElMessage.error('检测失败')
|
||||
}
|
||||
}
|
||||
|
||||
const quickScanAll = async () => {
|
||||
ElMessage.info('全量扫描任务已提交,请稍后查看结果')
|
||||
}
|
||||
|
||||
const getProgressColor = (percentage) => {
|
||||
if (percentage >= 90) return '#f56c6c'
|
||||
if (percentage >= 70) return '#e6a23c'
|
||||
return '#67c23a'
|
||||
}
|
||||
|
||||
const getAlertTypeName = (type) => {
|
||||
const names = {
|
||||
'ip_conflict': 'IP冲突',
|
||||
'unauthorized_access': '未授权接入',
|
||||
'subnet_full': '网段耗尽',
|
||||
'scan_failed': '扫描失败',
|
||||
'device_offline': '设备离线',
|
||||
'new_device_detected': '新设备发现',
|
||||
'mac_changed': 'MAC变更'
|
||||
}
|
||||
return names[type] || type
|
||||
}
|
||||
|
||||
const goToNetwork = (id) => {
|
||||
router.push(`/ips?networkId=${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadNetworkStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-total .stat-icon {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-ip .stat-icon {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-online .stat-icon {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-alert .stat-icon {
|
||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.alert-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.alert-item.critical {
|
||||
background-color: #fef0f0;
|
||||
}
|
||||
|
||||
.alert-item.error {
|
||||
background-color: #fdf6ec;
|
||||
}
|
||||
|
||||
.alert-item.warning {
|
||||
background-color: #fdf6ec;
|
||||
}
|
||||
|
||||
.alert-item.info {
|
||||
background-color: #f4f4f5;
|
||||
}
|
||||
|
||||
.alert-count {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert-item.critical .alert-count {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.alert-item.error .alert-count {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.alert-item.warning .alert-count {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.alert-item.info .alert-count {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.alert-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.alert-types {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.alert-type-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.alert-type-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.type-name {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<div class="ips-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<span>IP地址管理</span>
|
||||
<el-button type="success" @click="startBatchScan" :loading="scanning">
|
||||
<el-icon><Search /></el-icon>
|
||||
批量扫描
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="网段">
|
||||
<el-select v-model="filters.networkId" placeholder="选择网段" clearable @change="loadIPs" style="width: 280px">
|
||||
<el-option
|
||||
v-for="network in networks"
|
||||
:key="network.id"
|
||||
:label="`${network.cidr} (${network.name})`"
|
||||
:value="network.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="选择状态" clearable @change="loadIPs" style="width: 150px">
|
||||
<el-option label="在线" value="online" />
|
||||
<el-option label="离线" value="offline" />
|
||||
<el-option label="可用" value="available" />
|
||||
<el-option label="保留" value="reserved" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="搜索">
|
||||
<el-input v-model="filters.search" placeholder="IP/MAC/主机名" style="width: 200px" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadIPs">
|
||||
<el-icon><Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="resetFilters">
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<el-row :gutter="16" style="margin-bottom: 16px;">
|
||||
<el-col :span="6">
|
||||
<el-statistic title="总IP数" :value="stats.total" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="在线" :value="stats.online" value-style="color: #67c23a" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="离线" :value="stats.offline" value-style="color: #909399" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="保留" :value="stats.reserved" value-style="color: #e6a23c" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table :data="tableData" style="width: 100%" v-loading="loading" stripe height="calc(100vh - 380px)">
|
||||
<el-table-column prop="ip_address" label="IP地址" width="140" fixed="left">
|
||||
<template #default="scope">
|
||||
<span class="ip-address">{{ scope.row.ip_address }}</span>
|
||||
<el-tag v-if="scope.row.status === 'reserved'" type="warning" size="small" style="margin-left: 8px">保留</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mac_address" label="MAC地址" width="160">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.mac_address" class="mac-address">{{ scope.row.mac_address }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="vendor" label="厂商" width="150" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.vendor">{{ scope.row.vendor }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hostname" label="主机名" width="150" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.hostname">{{ scope.row.hostname }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusType(scope.row.status)" size="small">
|
||||
{{ getStatusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="owner" label="使用者" width="120">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.owner">{{ scope.row.owner }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_seen" label="最后发现" width="170">
|
||||
<template #default="scope">
|
||||
{{ scope.row.last_seen ? formatDate(scope.row.last_seen) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="primary" @click="scanIP(scope.row.ip_address)">
|
||||
<el-icon><Search /></el-icon>
|
||||
扫描
|
||||
</el-button>
|
||||
<el-button size="small" @click="editIP(scope.row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[50, 100, 200, 500]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadIPs"
|
||||
@current-change="loadIPs"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑IP对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="编辑IP信息"
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="ipForm" label-width="100px">
|
||||
<el-form-item label="IP地址">
|
||||
<el-input v-model="ipForm.ip_address" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="MAC地址">
|
||||
<el-input v-model="ipForm.mac_address" placeholder="MAC地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主机名">
|
||||
<el-input v-model="ipForm.hostname" placeholder="主机名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="使用者">
|
||||
<el-input v-model="ipForm.owner" placeholder="使用者名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="ipForm.notes" type="textarea" :rows="3" placeholder="备注信息" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveIP" :loading="submitting">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ipApi, networkApi, scanApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const scanning = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
const tableData = ref([])
|
||||
const networks = ref([])
|
||||
|
||||
const filters = reactive({
|
||||
networkId: null,
|
||||
status: '',
|
||||
search: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 100,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = tableData.value.length
|
||||
const online = tableData.value.filter(item => item.status === 'online').length
|
||||
const offline = tableData.value.filter(item => item.status === 'offline').length
|
||||
const reserved = tableData.value.filter(item => item.status === 'reserved').length
|
||||
return { total, online, offline, reserved }
|
||||
})
|
||||
|
||||
const ipForm = reactive({
|
||||
id: null,
|
||||
ip_address: '',
|
||||
mac_address: '',
|
||||
hostname: '',
|
||||
owner: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const loadNetworks = async () => {
|
||||
try {
|
||||
const result = await networkApi.getList({ limit: 1000 })
|
||||
networks.value = result.items
|
||||
} catch (error) {
|
||||
console.error('加载网段列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadIPs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 后端 Query 参数使用 snake_case,必须与 FastAPI 定义一致
|
||||
const params = {
|
||||
skip: (pagination.page - 1) * pagination.limit,
|
||||
limit: pagination.limit
|
||||
}
|
||||
if (filters.networkId) params.network_id = filters.networkId
|
||||
if (filters.status) params.status = filters.status
|
||||
if (filters.search) params.search = filters.search
|
||||
|
||||
const result = await ipApi.getList(params)
|
||||
tableData.value = result.items
|
||||
pagination.total = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载IP列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.networkId = null
|
||||
filters.status = ''
|
||||
filters.search = ''
|
||||
pagination.page = 1
|
||||
loadIPs()
|
||||
}
|
||||
|
||||
const scanIP = async (ipAddress) => {
|
||||
try {
|
||||
await scanApi.scanIP(ipAddress)
|
||||
ElMessage.success(`扫描 ${ipAddress} 完成`)
|
||||
loadIPs()
|
||||
} catch (error) {
|
||||
ElMessage.error(`扫描 ${ipAddress} 失败`)
|
||||
}
|
||||
}
|
||||
|
||||
const startBatchScan = async () => {
|
||||
if (!filters.networkId) {
|
||||
ElMessage.warning('请先选择网段')
|
||||
return
|
||||
}
|
||||
scanning.value = true
|
||||
try {
|
||||
ElMessage.info('扫描任务已提交')
|
||||
await scanApi.comprehensiveScan(filters.networkId)
|
||||
ElMessage.success('扫描完成')
|
||||
loadIPs()
|
||||
} catch (error) {
|
||||
// 详细错误由全局拦截器弹 toast;这里只 console 留排查
|
||||
console.error('扫描错误详情:', error)
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editIP = (row) => {
|
||||
ipForm.id = row.id
|
||||
ipForm.ip_address = row.ip_address
|
||||
ipForm.mac_address = row.mac_address || ''
|
||||
ipForm.hostname = row.hostname || ''
|
||||
ipForm.owner = row.owner || ''
|
||||
ipForm.notes = row.notes || ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveIP = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
await ipApi.update(ipForm.id, {
|
||||
mac_address: ipForm.mac_address,
|
||||
hostname: ipForm.hostname,
|
||||
owner: ipForm.owner,
|
||||
notes: ipForm.notes
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadIPs()
|
||||
} catch (error) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const types = {
|
||||
online: 'success',
|
||||
offline: 'info',
|
||||
available: 'primary',
|
||||
reserved: 'warning'
|
||||
}
|
||||
return types[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
available: '可用',
|
||||
reserved: '保留'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNetworks()
|
||||
// 从路由参数获取网段ID
|
||||
if (route.query.networkId) {
|
||||
filters.networkId = parseInt(route.query.networkId)
|
||||
}
|
||||
loadIPs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ip-address {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 500;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.mac-address {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<el-card class="login-card" shadow="hover">
|
||||
<div class="login-header">
|
||||
<el-icon :size="42" color="#409eff"><Monitor /></el-icon>
|
||||
<h2>IPAM 管理系统</h2>
|
||||
<p class="subtitle">企业级 IP 地址管理与资产台账</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
label-position="top"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
:prefix-icon="User"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
:loading="loading"
|
||||
@click="onSubmit"
|
||||
>
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<el-text size="small" type="info">默认账户 admin / admin123(首次登录后请立即修改密码)</el-text>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock, Monitor } from '@element-plus/icons-vue'
|
||||
import { loginAndStore } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const loginFormRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!loginFormRef.value) return
|
||||
try {
|
||||
await loginFormRef.value.validate()
|
||||
} catch (_) {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await loginAndStore({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
})
|
||||
ElMessage.success(`欢迎回来,${data?.user_info?.real_name || data?.user_info?.username || '管理员'}`)
|
||||
const next = route.query.next ? decodeURIComponent(route.query.next) : '/dashboard'
|
||||
router.push(next)
|
||||
} catch (error) {
|
||||
// axios 拦截器已弹过 toast;这里只 console 留排查
|
||||
console.error('登录失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 420px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
margin: 12px 0 4px 0;
|
||||
font-size: 22px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.login-header .subtitle {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<div class="networks-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<span>网段管理</span>
|
||||
<el-button type="primary" @click="showCreateDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加网段
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="分组">
|
||||
<el-select v-model="filters.groupName" placeholder="选择分组" clearable @change="loadNetworks">
|
||||
<el-option label="生产网" value="production" />
|
||||
<el-option label="办公网" value="office" />
|
||||
<el-option label="测试网" value="test" />
|
||||
<el-option label="管理网" value="management" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadNetworks">
|
||||
<el-icon><Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="resetFilters">
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table :data="tableData" style="width: 100%" v-loading="loading" stripe>
|
||||
<el-table-column prop="cidr" label="网段" width="140" />
|
||||
<el-table-column prop="name" label="名称" width="150" />
|
||||
<el-table-column prop="gateway" label="网关" width="140" />
|
||||
<el-table-column prop="group_name" label="分组" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.group_name" :type="getGroupTagType(scope.row.group_name)" size="small">
|
||||
{{ scope.row.group_name }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_ips" label="总IP数" width="100" align="center" />
|
||||
<el-table-column label="使用率" width="200">
|
||||
<template #default="scope">
|
||||
<el-progress
|
||||
:percentage="getUsagePercent(scope.row)"
|
||||
:color="getProgressColor(getUsagePercent(scope.row))"
|
||||
:stroke-width="12"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="primary" @click="viewIPs(scope.row.id)">
|
||||
<el-icon><View /></el-icon>
|
||||
查看IP
|
||||
</el-button>
|
||||
<el-button size="small" type="success" @click="startScan(scope.row.id)">
|
||||
<el-icon><Search /></el-icon>
|
||||
扫描
|
||||
</el-button>
|
||||
<el-button size="small" @click="editNetwork(scope.row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteNetwork(scope.row.id)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadNetworks"
|
||||
@current-change="loadNetworks"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 创建/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑网段' : '添加网段'"
|
||||
width="500px"
|
||||
@close="resetForm"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="网段CIDR" prop="cidr">
|
||||
<el-input v-model="form.cidr" placeholder="例如: 192.168.1.0/24" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="网段名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关" prop="gateway">
|
||||
<el-input v-model="form.gateway" placeholder="网关IP地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分组" prop="group_name">
|
||||
<el-select v-model="form.group_name" placeholder="选择分组">
|
||||
<el-option label="生产网" value="production" />
|
||||
<el-option label="办公网" value="office" />
|
||||
<el-option label="测试网" value="test" />
|
||||
<el-option label="管理网" value="management" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="描述信息" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { networkApi, scanApi } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const tableData = ref([])
|
||||
|
||||
const filters = reactive({
|
||||
groupName: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
id: null,
|
||||
cidr: '',
|
||||
name: '',
|
||||
gateway: '',
|
||||
group_name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
cidr: [
|
||||
{ required: true, message: '请输入网段CIDR', trigger: 'blur' },
|
||||
{ pattern: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, message: 'CIDR格式不正确', trigger: 'blur' }
|
||||
],
|
||||
name: [
|
||||
{ required: true, message: '请输入网段名称', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const loadNetworks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await networkApi.getList({
|
||||
skip: (pagination.page - 1) * pagination.limit,
|
||||
limit: pagination.limit,
|
||||
groupName: filters.groupName || undefined
|
||||
})
|
||||
tableData.value = result.items
|
||||
pagination.total = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载网段列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.groupName = ''
|
||||
pagination.page = 1
|
||||
loadNetworks()
|
||||
}
|
||||
|
||||
const showCreateDialog = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const editNetwork = (row) => {
|
||||
isEdit.value = true
|
||||
form.id = row.id
|
||||
form.cidr = row.cidr
|
||||
form.name = row.name
|
||||
form.gateway = row.gateway
|
||||
form.group_name = row.group_name
|
||||
form.description = row.description
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = null
|
||||
form.cidr = ''
|
||||
form.name = ''
|
||||
form.gateway = ''
|
||||
form.group_name = ''
|
||||
form.description = ''
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await networkApi.update(form.id, form)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await networkApi.create(form)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadNetworks()
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteNetwork = async (id) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该网段吗?删除后相关IP地址也会被删除', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await networkApi.delete(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadNetworks()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const viewIPs = (id) => {
|
||||
router.push(`/ips?networkId=${id}`)
|
||||
}
|
||||
|
||||
const startScan = async (id) => {
|
||||
try {
|
||||
ElMessage.info('扫描任务已提交')
|
||||
await scanApi.comprehensiveScan(id)
|
||||
ElMessage.success('扫描完成')
|
||||
loadNetworks()
|
||||
} catch (error) {
|
||||
// 详细错误由全局拦截器弹 toast;这里只 console 留排查
|
||||
console.error('扫描错误详情:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getUsagePercent = (row) => {
|
||||
const used = row.used_ips || 0
|
||||
const total = row.total_ips || 1
|
||||
return Math.round((used / total) * 100)
|
||||
}
|
||||
|
||||
const getProgressColor = (percentage) => {
|
||||
if (percentage >= 90) return '#f56c6c'
|
||||
if (percentage >= 70) return '#e6a23c'
|
||||
return '#67c23a'
|
||||
}
|
||||
|
||||
const getGroupTagType = (group) => {
|
||||
const types = {
|
||||
production: 'danger',
|
||||
office: 'primary',
|
||||
test: 'info',
|
||||
management: 'warning'
|
||||
}
|
||||
return types[group] || 'info'
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNetworks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div>个人中心 - 占位(待用户管理模块完成后填充)</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<div class="snmp-page">
|
||||
<el-tabs v-model="activeTab" type="card">
|
||||
<!-- 网络设备 -->
|
||||
<el-tab-pane label="网络设备" name="devices">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<span>网络设备列表</span>
|
||||
<el-button type="primary" @click="showDeviceDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加设备
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="devices" style="width: 100%" v-loading="loading" stripe>
|
||||
<el-table-column prop="name" label="设备名称" width="150" />
|
||||
<el-table-column prop="ip_address" label="IP地址" width="140">
|
||||
<template #default="scope">
|
||||
<span class="ip-address">{{ scope.row.ip_address }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="port" label="端口" width="80" align="center" />
|
||||
<el-table-column label="凭据" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.credential_name" type="info" size="small">
|
||||
{{ scope.row.credential_name }}
|
||||
</el-tag>
|
||||
<el-tag v-else type="danger" size="small">未配置</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="device_type" label="类型" width="100" align="center" />
|
||||
<el-table-column label="轮询状态" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.last_successful_poll" type="success" size="small">正常</el-tag>
|
||||
<el-tag v-else type="info" size="small">未轮询</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_polled_at" label="最后轮询" width="180">
|
||||
<template #default="scope">
|
||||
{{ scope.row.last_polled_at ? formatDate(scope.row.last_polled_at) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="success" @click="testConnection(scope.row.id)">
|
||||
<el-icon><Connection /></el-icon>
|
||||
测试连接
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click="pollDevice(scope.row.id)">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
轮询
|
||||
</el-button>
|
||||
<el-button size="small" @click="editDevice(scope.row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteDevice(scope.row.id)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- SNMP凭据 -->
|
||||
<el-tab-pane label="SNMP凭据" name="credentials">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<span>SNMP凭据管理</span>
|
||||
<el-button type="primary" @click="showCredentialDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加凭据
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="credentials" style="width: 100%" v-loading="loading" stripe>
|
||||
<el-table-column prop="name" label="凭据名称" width="150" />
|
||||
<el-table-column prop="version" label="版本" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.version === 'v3' ? 'warning' : 'primary'" size="small">
|
||||
{{ scope.row.version }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配置信息" min-width="200">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.version === 'v2c'">Community: {{ scope.row.community_string }}</span>
|
||||
<span v-else>User: {{ scope.row.username }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timeout" label="超时(秒)" width="100" align="center" />
|
||||
<el-table-column prop="retries" label="重试次数" width="100" align="center" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="editCredential(scope.row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCredential(scope.row.id)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ARP表 -->
|
||||
<el-tab-pane label="ARP表" name="arp">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<span>ARP地址表</span>
|
||||
<el-select v-model="arpDeviceId" placeholder="选择设备" style="width: 200px;" clearable>
|
||||
<el-option
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
:label="device.name"
|
||||
:value="device.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadARPTable" :loading="arpLoading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="arpEntries" style="width: 100%" v-loading="arpLoading" stripe>
|
||||
<el-table-column prop="ip_address" label="IP地址" width="140">
|
||||
<template #default="scope">
|
||||
<span class="ip-address">{{ scope.row.ip_address }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mac_address" label="MAC地址" width="160">
|
||||
<template #default="scope">
|
||||
<span class="mac-address">{{ scope.row.mac_address }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="interface" label="接口" width="120" />
|
||||
<el-table-column prop="last_seen" label="最后发现" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.last_seen) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 设备对话框 -->
|
||||
<el-dialog
|
||||
v-model="deviceDialogVisible"
|
||||
:title="isDeviceEdit ? '编辑设备' : '添加设备'"
|
||||
width="500px"
|
||||
@close="resetDeviceForm"
|
||||
>
|
||||
<el-form ref="deviceFormRef" :model="deviceForm" :rules="deviceRules" label-width="100px">
|
||||
<el-form-item label="设备名称" prop="name">
|
||||
<el-input v-model="deviceForm.name" placeholder="设备名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP地址" prop="ip_address">
|
||||
<el-input v-model="deviceForm.ip_address" placeholder="设备IP地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="端口" prop="port">
|
||||
<el-input-number v-model="deviceForm.port" :min="1" :max="65535" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SNMP凭据" prop="snmp_credential_id">
|
||||
<el-select v-model="deviceForm.snmp_credential_id" placeholder="选择凭据">
|
||||
<el-option
|
||||
v-for="cred in credentials"
|
||||
:key="cred.id"
|
||||
:label="cred.name"
|
||||
:value="cred.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备类型" prop="device_type">
|
||||
<el-select v-model="deviceForm.device_type" placeholder="选择类型">
|
||||
<el-option label="路由器" value="router" />
|
||||
<el-option label="核心交换机" value="core_switch" />
|
||||
<el-option label="汇聚交换机" value="distribution_switch" />
|
||||
<el-option label="接入交换机" value="access_switch" />
|
||||
<el-option label="防火墙" value="firewall" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="deviceForm.description" type="textarea" :rows="2" placeholder="描述信息" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="deviceDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveDevice" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 凭据对话框 -->
|
||||
<el-dialog
|
||||
v-model="credentialDialogVisible"
|
||||
:title="isCredentialEdit ? '编辑凭据' : '添加凭据'"
|
||||
width="600px"
|
||||
@close="resetCredentialForm"
|
||||
>
|
||||
<el-form ref="credentialFormRef" :model="credentialForm" :rules="credentialRules" label-width="120px">
|
||||
<el-form-item label="凭据名称" prop="name">
|
||||
<el-input v-model="credentialForm.name" placeholder="凭据名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SNMP版本" prop="version">
|
||||
<el-select v-model="credentialForm.version" placeholder="选择版本">
|
||||
<el-option label="v2c" value="v2c" />
|
||||
<el-option label="v3" value="v3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="credentialForm.version === 'v2c'">
|
||||
<el-form-item label="Community字符串" prop="community_string">
|
||||
<el-input v-model="credentialForm.community_string" placeholder="SNMP Community字符串" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-if="credentialForm.version === 'v3'">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="credentialForm.username" placeholder="SNMPv3用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证密码" prop="auth_password">
|
||||
<el-input v-model="credentialForm.auth_password" type="password" placeholder="认证密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证协议" prop="auth_protocol">
|
||||
<el-select v-model="credentialForm.auth_protocol" placeholder="选择认证协议">
|
||||
<el-option label="MD5" value="md5" />
|
||||
<el-option label="SHA" value="sha" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="加密密码" prop="priv_password">
|
||||
<el-input v-model="credentialForm.priv_password" type="password" placeholder="加密密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="加密协议" prop="priv_protocol">
|
||||
<el-select v-model="credentialForm.priv_protocol" placeholder="选择加密协议">
|
||||
<el-option label="DES" value="des" />
|
||||
<el-option label="AES" value="aes" />
|
||||
<el-option label="AES256" value="aes256" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="超时时间(秒)" prop="timeout">
|
||||
<el-input-number v-model="credentialForm.timeout" :min="1" :max="30" />
|
||||
</el-form-item>
|
||||
<el-form-item label="重试次数" prop="retries">
|
||||
<el-input-number v-model="credentialForm.retries" :min="0" :max="5" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="credentialForm.description" type="textarea" :rows="2" placeholder="描述信息" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="credentialDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCredential" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { snmpApi } from '@/api'
|
||||
|
||||
const activeTab = ref('devices')
|
||||
const loading = ref(false)
|
||||
const arpLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const devices = ref([])
|
||||
const credentials = ref([])
|
||||
const arpEntries = ref([])
|
||||
const arpDeviceId = ref(null)
|
||||
|
||||
// 设备相关
|
||||
const deviceDialogVisible = ref(false)
|
||||
const isDeviceEdit = ref(false)
|
||||
const deviceFormRef = ref(null)
|
||||
const deviceForm = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
ip_address: '',
|
||||
port: 161,
|
||||
snmp_credential_id: null,
|
||||
device_type: 'other',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const deviceRules = {
|
||||
name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||
ip_address: [{ required: true, message: '请输入IP地址', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 凭据相关
|
||||
const credentialDialogVisible = ref(false)
|
||||
const isCredentialEdit = ref(false)
|
||||
const credentialFormRef = ref(null)
|
||||
const credentialForm = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
version: 'v2c',
|
||||
community_string: 'public',
|
||||
username: '',
|
||||
auth_password: '',
|
||||
auth_protocol: 'md5',
|
||||
priv_password: '',
|
||||
priv_protocol: 'des',
|
||||
timeout: 3,
|
||||
retries: 2,
|
||||
description: ''
|
||||
})
|
||||
|
||||
const credentialRules = {
|
||||
name: [{ required: true, message: '请输入凭据名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
const result = await snmpApi.getDevices()
|
||||
devices.value = result.items
|
||||
} catch (error) {
|
||||
console.error('加载设备列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCredentials = async () => {
|
||||
try {
|
||||
const result = await snmpApi.getCredentials()
|
||||
credentials.value = result.items
|
||||
} catch (error) {
|
||||
console.error('加载凭据列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadARPTable = async () => {
|
||||
if (!arpDeviceId.value) {
|
||||
ElMessage.warning('请先选择设备')
|
||||
return
|
||||
}
|
||||
arpLoading.value = true
|
||||
try {
|
||||
const result = await snmpApi.getARPTable(arpDeviceId.value)
|
||||
arpEntries.value = result.entries || []
|
||||
} catch (error) {
|
||||
ElMessage.error('加载ARP表失败')
|
||||
} finally {
|
||||
arpLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 设备操作
|
||||
const showDeviceDialog = () => {
|
||||
isDeviceEdit.value = false
|
||||
resetDeviceForm()
|
||||
deviceDialogVisible.value = true
|
||||
}
|
||||
|
||||
const editDevice = (row) => {
|
||||
isDeviceEdit.value = true
|
||||
deviceForm.id = row.id
|
||||
deviceForm.name = row.name
|
||||
deviceForm.ip_address = row.ip_address
|
||||
deviceForm.port = row.port
|
||||
deviceForm.snmp_credential_id = row.snmp_credential_id
|
||||
deviceForm.device_type = row.device_type
|
||||
deviceForm.description = row.description
|
||||
deviceDialogVisible.value = true
|
||||
}
|
||||
|
||||
const resetDeviceForm = () => {
|
||||
deviceForm.id = null
|
||||
deviceForm.name = ''
|
||||
deviceForm.ip_address = ''
|
||||
deviceForm.port = 161
|
||||
deviceForm.snmp_credential_id = null
|
||||
deviceForm.device_type = 'other'
|
||||
deviceForm.description = ''
|
||||
deviceFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const saveDevice = async () => {
|
||||
await deviceFormRef.value.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isDeviceEdit.value) {
|
||||
await snmpApi.updateDevice(deviceForm.id, deviceForm)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await snmpApi.createDevice(deviceForm)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
deviceDialogVisible.value = false
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
ElMessage.error(isDeviceEdit.value ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDevice = async (id) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该设备吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await snmpApi.deleteDevice(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async (id) => {
|
||||
try {
|
||||
const result = await snmpApi.testConnection(id)
|
||||
if (result.success) {
|
||||
ElMessage.success('连接成功')
|
||||
} else {
|
||||
ElMessage.error(`连接失败: ${result.info?.error || '未知错误'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('连接测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
const pollDevice = async (id) => {
|
||||
try {
|
||||
ElMessage.info('轮询任务已提交')
|
||||
await snmpApi.pollDevice(id)
|
||||
ElMessage.success('轮询完成')
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
ElMessage.error('轮询失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 凭据操作
|
||||
const showCredentialDialog = () => {
|
||||
isCredentialEdit.value = false
|
||||
resetCredentialForm()
|
||||
credentialDialogVisible.value = true
|
||||
}
|
||||
|
||||
const editCredential = (row) => {
|
||||
isCredentialEdit.value = true
|
||||
credentialForm.id = row.id
|
||||
credentialForm.name = row.name
|
||||
credentialForm.version = row.version
|
||||
credentialForm.community_string = row.community_string
|
||||
credentialForm.username = row.username
|
||||
credentialForm.auth_password = row.auth_password
|
||||
credentialForm.auth_protocol = row.auth_protocol
|
||||
credentialForm.priv_password = row.priv_password
|
||||
credentialForm.priv_protocol = row.priv_protocol
|
||||
credentialForm.timeout = row.timeout
|
||||
credentialForm.retries = row.retries
|
||||
credentialForm.description = row.description
|
||||
credentialDialogVisible.value = true
|
||||
}
|
||||
|
||||
const resetCredentialForm = () => {
|
||||
credentialForm.id = null
|
||||
credentialForm.name = ''
|
||||
credentialForm.version = 'v2c'
|
||||
credentialForm.community_string = 'public'
|
||||
credentialForm.username = ''
|
||||
credentialForm.auth_password = ''
|
||||
credentialForm.auth_protocol = 'md5'
|
||||
credentialForm.priv_password = ''
|
||||
credentialForm.priv_protocol = 'des'
|
||||
credentialForm.timeout = 3
|
||||
credentialForm.retries = 2
|
||||
credentialForm.description = ''
|
||||
credentialFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const saveCredential = async () => {
|
||||
await credentialFormRef.value.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isCredentialEdit.value) {
|
||||
await snmpApi.updateCredential(credentialForm.id, credentialForm)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await snmpApi.createCredential(credentialForm)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
credentialDialogVisible.value = false
|
||||
loadCredentials()
|
||||
} catch (error) {
|
||||
ElMessage.error(isCredentialEdit.value ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCredential = async (id) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该凭据吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await snmpApi.deleteCredential(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadCredentials()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loading.value = true
|
||||
Promise.all([loadDevices(), loadCredentials()]).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ip-address {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 500;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.mac-address {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<div class="scan-page">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>选择网段</span>
|
||||
</template>
|
||||
<el-select
|
||||
v-model="selectedNetworkId"
|
||||
placeholder="请选择要扫描的网段"
|
||||
style="width: 100%"
|
||||
@change="onNetworkChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="network in networks"
|
||||
:key="network.id"
|
||||
:label="`${network.cidr} (${network.name})`"
|
||||
:value="network.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="selectedNetwork" class="network-info" style="margin-top: 16px;">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="网段">{{ selectedNetwork.cidr }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ selectedNetwork.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总IP数">{{ selectedNetwork.total_ips }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card style="margin-top: 20px;">
|
||||
<template #header>
|
||||
<span>扫描选项</span>
|
||||
</template>
|
||||
<el-checkbox-group v-model="scanOptions">
|
||||
<el-checkbox value="ping">Ping 检测</el-checkbox>
|
||||
<el-checkbox value="arp">ARP 扫描</el-checkbox>
|
||||
<el-checkbox value="dns">反向 DNS</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<el-divider />
|
||||
<el-button
|
||||
type="primary"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:disabled="!selectedNetwork || scanning"
|
||||
:loading="scanning"
|
||||
@click="startScan"
|
||||
>
|
||||
<el-icon><Search /></el-icon>
|
||||
开始扫描
|
||||
</el-button>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>扫描结果</span>
|
||||
<el-button-group>
|
||||
<el-button size="small" @click="loadIPs">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button size="small" @click="exportResults">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 统计信息 -->
|
||||
<el-row :gutter="16" v-if="selectedNetwork">
|
||||
<el-col :span="6">
|
||||
<el-statistic title="扫描总数" :value="scanStats.total" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="在线" :value="scanStats.online" value-style="color: #67c23a" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="有MAC" :value="scanStats.withMac" value-style="color: #409eff" />
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="有主机名" :value="scanStats.withHostname" value-style="color: #909399" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-table
|
||||
:data="tableData"
|
||||
style="width: 100%; margin-top: 16px;"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
height="calc(100vh - 320px)"
|
||||
>
|
||||
<el-table-column prop="ip_address" label="IP地址" width="140" fixed="left">
|
||||
<template #default="scope">
|
||||
<span class="ip-address">{{ scope.row.ip_address }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="在线状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 'online' ? 'success' : 'info'" size="small">
|
||||
{{ scope.row.status === 'online' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mac_address" label="MAC地址" width="160">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.mac_address" class="mac-address">{{ scope.row.mac_address }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="vendor" label="厂商" width="180" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.vendor">{{ scope.row.vendor }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hostname" label="主机名" min-width="150" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.hostname">{{ scope.row.hostname }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="owner" label="使用者" width="100">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.owner">{{ scope.row.owner }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="primary" @click="scanSingleIP(scope.row.ip_address)">
|
||||
单独扫描
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[50, 100, 200]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadIPs"
|
||||
@current-change="loadIPs"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { networkApi, scanApi } from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const scanning = ref(false)
|
||||
|
||||
const networks = ref([])
|
||||
const selectedNetworkId = ref(null)
|
||||
const selectedNetwork = computed(() => {
|
||||
if (!selectedNetworkId.value) return null
|
||||
return networks.value.find(n => n.id === selectedNetworkId.value) || null
|
||||
})
|
||||
const tableData = ref([])
|
||||
|
||||
const scanOptions = ref(['ping', 'arp', 'dns'])
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 100,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const scanStats = computed(() => {
|
||||
const total = tableData.value.length
|
||||
const online = tableData.value.filter(item => item.status === 'online').length
|
||||
const withMac = tableData.value.filter(item => item.mac_address).length
|
||||
const withHostname = tableData.value.filter(item => item.hostname).length
|
||||
return { total, online, withMac, withHostname }
|
||||
})
|
||||
|
||||
const loadNetworks = async () => {
|
||||
try {
|
||||
const result = await networkApi.getList({ limit: 1000 })
|
||||
networks.value = result.items
|
||||
} catch (error) {
|
||||
ElMessage.error('加载网段列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onNetworkChange = (networkId) => {
|
||||
pagination.page = 1
|
||||
selectedNetworkId.value = networkId
|
||||
loadIPs()
|
||||
}
|
||||
|
||||
const loadIPs = async () => {
|
||||
if (!selectedNetworkId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await networkApi.getIPs(selectedNetworkId.value, {
|
||||
skip: (pagination.page - 1) * pagination.limit,
|
||||
limit: pagination.limit
|
||||
})
|
||||
tableData.value = result.items
|
||||
pagination.total = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载IP列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startScan = async () => {
|
||||
if (!selectedNetworkId.value) return
|
||||
scanning.value = true
|
||||
try {
|
||||
ElMessage.info(`开始扫描网段 ${selectedNetwork.value?.cidr}`)
|
||||
await scanApi.comprehensiveScan(selectedNetworkId.value, {
|
||||
enable_ping: scanOptions.value.includes('ping'),
|
||||
enable_arp: scanOptions.value.includes('arp'),
|
||||
enable_dns: scanOptions.value.includes('dns')
|
||||
})
|
||||
ElMessage.success('扫描完成')
|
||||
loadIPs()
|
||||
} catch (error) {
|
||||
console.error('扫描错误详情:', error)
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const scanSingleIP = async (ipAddress) => {
|
||||
try {
|
||||
await scanApi.scanIP(ipAddress)
|
||||
ElMessage.success(`扫描 ${ipAddress} 完成`)
|
||||
loadIPs()
|
||||
} catch (error) {
|
||||
ElMessage.error(`扫描 ${ipAddress} 失败`)
|
||||
}
|
||||
}
|
||||
|
||||
const exportResults = () => {
|
||||
if (!selectedNetwork.value) {
|
||||
ElMessage.warning('请先选择网段')
|
||||
return
|
||||
}
|
||||
const headers = ['IP地址', '状态', 'MAC地址', '厂商', '主机名', '使用者']
|
||||
const rows = tableData.value.map(item => [
|
||||
item.ip_address,
|
||||
item.status === 'online' ? '在线' : '离线',
|
||||
item.mac_address || '',
|
||||
item.vendor || '',
|
||||
item.hostname || '',
|
||||
item.owner || ''
|
||||
])
|
||||
const csvContent = [headers, ...rows].map(row => row.join(',')).join('\n')
|
||||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `scan_result_${selectedNetwork.value?.cidr?.replace('/', '-')}.csv`
|
||||
link.click()
|
||||
ElMessage.success('导出成功')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNetworks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.ip-address {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 500;
|
||||
color: #409eff;
|
||||
}
|
||||
.mac-address {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<span>用户管理</span>
|
||||
<el-button type="primary" @click="showCreateDialog">
|
||||
<el-icon><Plus /></el-icon> 新建用户
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 140px">
|
||||
<el-option label="正常" value="active" />
|
||||
<el-option label="禁用" value="inactive" />
|
||||
<el-option label="锁定" value="locked" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="filters.role" placeholder="全部" clearable style="width: 160px">
|
||||
<el-option label="超级管理员" value="super_admin" />
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="操作员" value="operator" />
|
||||
<el-option label="访客" value="viewer" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadUsers">
|
||||
<el-icon><Search /></el-icon> 查询
|
||||
</el-button>
|
||||
<el-button @click="resetFilters">
|
||||
<el-icon><Refresh /></el-icon> 重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="users" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="60" align="center" />
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="real_name" label="姓名" width="120" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" />
|
||||
<el-table-column prop="phone" label="电话" width="130" />
|
||||
<el-table-column label="角色" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="roleTagType(scope.row.role)" effect="plain" size="small">
|
||||
{{ roleLabel(scope.row.role) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusTagType(scope.row.status)" effect="plain" size="small">
|
||||
{{ statusLabel(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后登录" width="170">
|
||||
<template #default="scope">
|
||||
{{ scope.row.last_login_at ? formatDate(scope.row.last_login_at) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
size="small"
|
||||
:type="scope.row.status === 'active' ? 'warning' : 'success'"
|
||||
@click="toggleStatus(scope.row)"
|
||||
:disabled="scope.row.id === currentUserId"
|
||||
>
|
||||
{{ scope.row.status === 'active' ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.status === 'locked'"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="unlockUser(scope.row)"
|
||||
>
|
||||
解锁
|
||||
</el-button>
|
||||
<el-button size="small" @click="showResetPasswordDialog(scope.row)">
|
||||
重置密码
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="deleteUser(scope.row)"
|
||||
:disabled="scope.row.id === currentUserId"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadUsers"
|
||||
@current-change="loadUsers"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新建用户对话框 -->
|
||||
<el-dialog v-model="createDialogVisible" title="新建用户" width="500px" @close="resetCreateForm">
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="createForm.username" placeholder="登录用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="createForm.password" type="password" placeholder="≥6 位" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="real_name">
|
||||
<el-input v-model="createForm.real_name" placeholder="真实姓名(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="createForm.email" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电话" prop="phone">
|
||||
<el-input v-model="createForm.phone" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="role">
|
||||
<el-select v-model="createForm.role" style="width: 100%">
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="操作员" value="operator" />
|
||||
<el-option label="访客" value="viewer" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitCreate">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重置密码对话框 -->
|
||||
<el-dialog v-model="resetDialogVisible" title="重置密码" width="420px">
|
||||
<el-form :model="resetForm" label-width="100px">
|
||||
<el-form-item label="目标用户">
|
||||
<el-text>{{ resetTarget?.username }} ({{ resetTarget?.real_name || '-' }})</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="resetForm.new_password" type="password" placeholder="≥6 位" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="resetDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitReset">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { userApi } from '@/api/auth'
|
||||
import { authStore } from '@/utils/auth'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const users = ref([])
|
||||
|
||||
const filters = reactive({ status: '', role: '' })
|
||||
const pagination = reactive({ page: 1, limit: 20, total: 0 })
|
||||
|
||||
const currentUserId = computed(() => authStore.getUserInfo()?.id)
|
||||
|
||||
const createDialogVisible = ref(false)
|
||||
const createFormRef = ref(null)
|
||||
const createForm = reactive({
|
||||
username: '', password: '', real_name: '', email: '', phone: '', role: 'viewer',
|
||||
})
|
||||
const createRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, min: 6, message: '密码至少 6 位', trigger: 'blur' }],
|
||||
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const resetDialogVisible = ref(false)
|
||||
const resetForm = reactive({ new_password: '' })
|
||||
const resetTarget = ref(null)
|
||||
|
||||
const ROLE_MAP = {
|
||||
super_admin: { label: '超级管理员', tagType: 'danger' },
|
||||
admin: { label: '管理员', tagType: 'warning' },
|
||||
operator: { label: '操作员', tagType: 'success' },
|
||||
viewer: { label: '访客', tagType: 'info' },
|
||||
}
|
||||
const STATUS_MAP = {
|
||||
active: { label: '正常', tagType: 'success' },
|
||||
inactive: { label: '禁用', tagType: 'info' },
|
||||
locked: { label: '锁定', tagType: 'danger' },
|
||||
}
|
||||
|
||||
function roleLabel(r) { return ROLE_MAP[r]?.label || r || '-' }
|
||||
function roleTagType(r) { return ROLE_MAP[r]?.tagType || '' }
|
||||
function statusLabel(s) { return STATUS_MAP[s]?.label || s || '-' }
|
||||
function statusTagType(s) { return STATUS_MAP[s]?.tagType || '' }
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '-'
|
||||
try {
|
||||
const dt = new Date(d)
|
||||
if (isNaN(dt.getTime())) return d
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
|
||||
} catch { return d }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await userApi.getList({
|
||||
skip: (pagination.page - 1) * pagination.limit,
|
||||
limit: pagination.limit,
|
||||
status: filters.status || undefined,
|
||||
role: filters.role || undefined,
|
||||
})
|
||||
users.value = result.items || []
|
||||
pagination.total = result.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载用户列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.status = ''
|
||||
filters.role = ''
|
||||
pagination.page = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function showCreateDialog() {
|
||||
createDialogVisible.value = true
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
Object.assign(createForm, {
|
||||
username: '', password: '', real_name: '', email: '', phone: '', role: 'viewer',
|
||||
})
|
||||
createFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createFormRef.value) return
|
||||
try {
|
||||
await createFormRef.value.validate()
|
||||
} catch (_) { return }
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await userApi.create({ ...createForm })
|
||||
ElMessage.success('用户创建成功')
|
||||
createDialogVisible.value = false
|
||||
pagination.page = 1
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
console.error('创建用户失败:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row) {
|
||||
const next = row.status === 'active' ? 'inactive' : 'active'
|
||||
const action = next === 'active' ? '启用' : '禁用'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要${action}用户 ${row.username} 吗?`,
|
||||
'提示', { type: 'warning', confirmButtonText: action, cancelButtonText: '取消' }
|
||||
)
|
||||
} catch (_) { return }
|
||||
|
||||
try {
|
||||
await userApi.updateStatus(row.id, next)
|
||||
ElMessage.success(`已${action}`)
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
console.error('更新状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function unlockUser(row) {
|
||||
try {
|
||||
await userApi.unlock(row.id)
|
||||
ElMessage.success('用户已解锁')
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
console.error('解锁失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function showResetPasswordDialog(row) {
|
||||
resetTarget.value = row
|
||||
resetForm.new_password = ''
|
||||
resetDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (!resetForm.new_password || resetForm.new_password.length < 6) {
|
||||
ElMessage.warning('新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await userApi.resetPassword(resetTarget.value.id, resetForm.new_password)
|
||||
ElMessage.success('密码已重置,用户所有会话已失效')
|
||||
resetDialogVisible.value = false
|
||||
} catch (error) {
|
||||
console.error('重置密码失败:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除用户 ${row.username} 吗?(软删除,标记为禁用)`,
|
||||
'提示', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch (_) { return }
|
||||
|
||||
try {
|
||||
await userApi.delete(row.id)
|
||||
ElMessage.success('用户已删除')
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user