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:
Your Name
2026-07-18 09:45:09 +08:00
commit 1e1e5957e8
103 changed files with 13196 additions and 0 deletions
+27
View File
@@ -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
+277
View File
@@ -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
+19
View File
@@ -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
+28
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
# app/__init__.py
Binary file not shown.
Binary file not shown.
+14
View File
@@ -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.
+182
View File
@@ -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
}
+211
View File
@@ -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 可能未启动"
}
+164
View File
@@ -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
+359
View File
@@ -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": "用户已删除"}
+209
View File
@@ -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
}
}
+162
View File
@@ -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
+180
View File
@@ -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": "删除成功"}
+108
View File
@@ -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
}
+555
View File
@@ -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
}
}
+5
View File
@@ -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.
+35
View File
@@ -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()
+31
View File
@@ -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()
+158
View File
@@ -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)
+71
View File
@@ -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.
+120
View File
@@ -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())
+37
View File
@@ -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())
+97
View File
@@ -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")
+119
View File
@@ -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")
+190
View File
@@ -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")
+173
View File
@@ -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
+496
View File
@@ -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
+155
View File
@@ -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)
}
+143
View File
@@ -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()
+177
View File
@@ -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
+148
View File
@@ -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
+520
View File
@@ -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 凭据缺少 usernameV3 强制要求 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
+420
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
# app/tasks/__init__.py
from app.tasks.celery_app import celery_app
__all__ = ['celery_app']
+55
View File
@@ -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分钟
},
}
+215
View File
@@ -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
}
+16
View File
@@ -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
+103
View File
@@ -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:
+260
View File
@@ -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_addressesIP地址表)
```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_credentialsSNMP凭据表)
```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` 配置文件。
+24
View File
@@ -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?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -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).
+13
View File
@@ -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>
+1839
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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

+24
View File
@@ -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

+23
View File
@@ -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>
+82
View File
@@ -0,0 +1,82 @@
import api from '@/utils/api'
import { authStore } from '@/utils/auth'
// 登录用 form-dataOAuth2PasswordRequestForm 期望 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 headerutils/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 tokenbest-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 }
+176
View File
@@ -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

+1
View File
@@ -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

+95
View File
@@ -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>
+301
View File
@@ -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>
+22
View File
@@ -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')
+110
View File
@@ -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
+296
View File
@@ -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);
}
}
+118
View File
@@ -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 tokenrefresh 失败就跳登录
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
+56
View File
@@ -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)
},
}
+575
View File
@@ -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>
+405
View File
@@ -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>
+372
View File
@@ -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>
+141
View File
@@ -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>
+333
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
<template>
<div>个人中心 - 占位待用户管理模块完成后填充</div>
</template>
<script setup>
</script>
+563
View File
@@ -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>
+293
View File
@@ -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>
+363
View File
@@ -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