14 Commits

Author SHA1 Message Date
Your Name 11d830b496 fix(snmp): 修复 SNMP 采集 ARP 表时只补 MAC 不补厂商的问题
问题原因:
- snmp_service.py get_arp_table 第267行条件为
  if ip_addr and not ip_addr.mac_address:
- 只给原本没有 MAC 的 IP 设置 MAC + 厂商
- 如果 IP 已有 MAC(来自 Ping/ARP 扫描)但没有 vendor,
  则永远不会补全厂商

解决方案:
- 拆分为两个 if:
  1. if not ip_addr.mac_address: 写入 MAC
  2. if not ip_addr.vendor and ip_addr.mac_address: 补全厂商
- 确保已有 MAC 但无厂商的 IP 也能被补全
- 已有 backfill_vendor.py 脚本可批量回填历史数据
2026-07-23 15:39:59 +08:00
Your Name 934b604b65 fix(mac-vendor): 修复 OUI 数据库 key 格式不匹配导致永远查不到厂商
问题原因:
- oui.txt 中的 key 格式为 AABBCC(无冒号,6位十六进制)
- 代码中提取的 oui = mac[:8] 是 AA:BB:CC(带冒号,8字符)
- db.get(oui) 永远匹配不到,且回退的遍历逻辑在4万条字典上
  每次查都全扫描,性能极差

解决方案:
- 提取 OUI 后去掉冒号再查询:oui.replace(':', '')
- 直接 db.get() 精确匹配,O(1) 时间复杂度
2026-07-23 15:36:47 +08:00
Your Name 13c457acb3 fix(network): 修复网段 total_ips 包含网络地址和广播地址的问题
问题原因:
- calculate_total_ips 使用 network.num_addresses 计算总IP数量
- 该值包含网络地址(第一个IP)和广播地址(最后一个IP)
- 而 _create_ip_addresses 使用 hosts() 生成IP列表,排除这两个地址
- 导致 total_ips 比实际存储的IP记录多2个
- 例: /24 显示 256 个IP,实际只有 254 条记录

解决方案:
1. calculate_total_ips 改为 num_addresses - 2(排除网络地址和广播地址)
2. 对于 /31 /32 等没有网络/广播地址的网段,特殊处理
3. 提供 fix_network_total_ips.py 迁移脚本修复已有数据
2026-07-23 15:33:13 +08:00
Your Name 05cd71ca1a feat(mac-vendor): 本地 OUI 数据库替代在线查询,修复厂商识别失败
问题原因:
- mac_vendor_lookup 库依赖从 IEEE 官网在线下载 OUI 数据库
- 其他机器无网络或网络不通时,内置精简版数据库不全
- 导致 SNMP 扫描到 MAC 地址后无法识别厂商

解决方案:
1. 从 IEEE OUI 官方数据库下载完整数据 (39782 条记录)
   保存为 backend/app/data/oui.txt 纳入版本管理
2. 修改 EnhancedScanService.get_mac_vendor(),查询顺序:
   1️⃣ 本地 OUI 数据库(免网络、高性能)
   2️⃣ mac_vendor_lookup 在线库(回退)
   3️⃣ 硬编码常用厂商映射(最终回退)
3. start.sh 启动时检测 OUI 文件是否存在,不存在自动下载
4. 支持 AA:BB:CC、AABBCC、aa-bb-cc、001c.14aa.bbcc 多种 MAC 格式
2026-07-23 15:31:54 +08:00
Your Name ee0552f837 fix(frontend): 移除 element-plus alias 防止 CSS 路径解析失败
问题原因:
- 上次提交在 vite.config.js resolve.alias 中将 element-plus
  直接映射到 es/index.mjs 文件路径
- 这导致 import 'element-plus/dist/index.css' 时,
  Vite 拼接路径为 es/index.mjs/dist/index.css,文件不存在
- 报错: Failed to resolve import 'element-plus/dist/index.css'

解决方案:
- 移除 element-plus 的 resolve.alias 映射
- 包名保留为包名,Vite 的 node_modules 解析机制可正确处理
  element-plus 的 JS 入口和 CSS 路径
- start.sh 已有清缓存+自动重试机制兜底
2026-07-23 15:05:35 +08:00
Your Name 094d2cc112 fix(snmp): 修复异步函数同步调用导致'coroutine object is not an iterator'错误
问题原因:
- pysnmp.hlapi.asyncio 的 getCmd/bulkCmd/nextCmd 都是 async def 协程函数
- 返回的是 coroutine 对象,需要用 await 获取结果
- 原代码用 next() 或 for...in 同步迭代协程对象,报错:
  'coroutine' object is not an iterator

解决方案:
1. snmp_service.py: 所有 SNMP 操作改为 async def
   - test_connection(): getCmd 改为 await getCmd()
   - get_arp_table(): bulkCmd 改为 await bulkCmd()
   - get_mac_address_table(): bulkCmd 改为 await bulkCmd()
   - get_interfaces(): bulkCmd 改为 await bulkCmd()
   - poll_device(): 所有子调用加 await
2. api/v1/snmp.py: API 路由改为 async def + await

bulkCmd await 后返回 (errorIndication, errorStatus, errorIndex, varBindTable)
其中 varBindTable 是列表的列表(table 形式),需双层循环解析
2026-07-23 15:01:18 +08:00
Your Name 8b1df674ec fix(frontend): 修复 element-plus 解析失败问题
问题原因:
- element-plus 2.14.x 的 package.json exports 包含 ./*: ./ 通配符
- Vite 5.x 的 import-analysis 在某些场景下无法解析该 exports 配置
- 新机器 git pull + npm install 后 Vite 缓存为空,首次预构建可能失败
- 报错: Failed to resolve entry for package 'element-plus'

解决方案:
1. vite.config.js resolve.alias 添加 element-plus 显式 fallback 路径
   即使 exports 解析失败也能直接定位到 es/index.mjs
2. start.sh 启动前自动清理 .vite 缓存,避免过期缓存问题
3. start.sh 失败自动重试+清缓存机制,提高首次启动成功率
2026-07-23 14:49:54 +08:00
Your Name 6076e174d5 fix(frontend): 撤回 optimizeDeps.include 避免 element-plus 解析失败导致启动崩溃
问题原因:
- 上一次提交添加的 optimizeDeps.include 让 Vite 在启动阶段
  就硬解析 element-plus, 包安装不完整时直接崩 (服务器起不来)
- predev 脚本每次清缓存, 如果源包有问题则重建必然失败

修正:
- 移除 optimizeDeps.include, Vite 自动发现预构建即可
- predev 改为可选的 dev:clean 脚本, 不影响正常启动
- start.sh 加固: 检查 element-plus/es/index.mjs 是否存在
  不存在则自动 npm install

影响范围:
- 其他服务器 git pull 后, start.sh 会自动检测并安装缺失依赖
- 正常 npm run dev 不再每次清缓存, 启动更快
2026-07-23 14:35:17 +08:00
Your Name 8a94f535de fix(frontend): 修复 Vite 缓存过期导致 element-plus 入口解析失败
问题原因:
- Vite dev server 长时间运行(跨天)后 deps 缓存过期
- import-analysis 阶段无法解析 element-plus 包入口
- 报错: Failed to resolve entry for package "element-plus"

解决方案:
- vite.config.js 添加 optimizeDeps.include 显式预构建 element-plus 等核心依赖
- package.json 添加 predev 脚本, npm run dev 前自动清 .vite 缓存
- 防止其他服务器 git pull 后同样踩坑
2026-07-23 14:27:41 +08:00
Your Name ebb04f9ad8 fix(frontend): 修复 vite 命令冲突问题
问题原因:
- 系统 PATH 中有另一个也叫 'vite' 的 Qt GUI 程序
- 优先级高于 npm 的 vite,导致 npm run dev 调用了错误的程序
- 出现 Qt X11 显示相关的错误

解决方案:
- 直接使用 node 调用 vite 的完整路径
- 避免依赖 PATH 解析,确保调用正确的 vite

验证:
- node node_modules/vite/bin/vite.js --version
   vite/5.4.21 linux-x64 node-v23.11.1
2026-07-23 14:16:08 +08:00
Your Name f66e4d41c9 fix(deps): 修复 pyasn1 版本冲突问题
问题原因:
- pyasn1>=0.5.0 移除了 pyasn1.compat.octets 模块
- python-jose==3.5.0 强制要求 pyasn1>=0.5.0
- 形成版本冲突,导致 pysnmp 无法导入

解决方案:
- pyasn1==0.4.8 (保留 compat.octets 的版本)
- pyasn1-modules==0.2.8 (配套版本)
- python-jose[cryptography]==3.3.0 (兼容 pyasn1 0.4.8)

验证:
- Python 3.10 & 3.11 均可正常导入
- FastAPI 应用可正常加载 (95 routes)
2026-07-23 14:10:59 +08:00
Your Name e0cba105b6 fix(deps): 修复 pysnmp 兼容性问题
问题原因:
- pysnmp==7.1.15 使用已废弃的 asyncio.coroutine 装饰器
- 该装饰器在 Python 3.11 中被完全移除,导致导入失败

解决方案:
- 替换为 pysnmp-lextudio==5.0.31
- 这是社区维护的分支,兼容 Python 3.6+
- API 完全兼容,无需修改业务代码

验证:
- Python 3.10 & 3.11 均可正常导入
- FastAPI 应用可正常加载 (95 routes)
- SNMPService.OID_SYS_DESCR 可正常访问
2026-07-23 14:06:39 +08:00
Your Name a8e764b102 feat(python3.10): 适配 Python 3.10 并修复 SNMP 问题
修改内容:
1. 修复 SNMP 凭据显示问题:确保设备列表返回 snmp_credential_id
2. 修复清除 SNMP 凭据功能:新增 clear_snmp_credential 参数
3. 前端适配:处理清除凭据时的参数转换
4. 更新 requirements.txt:添加 Python 3.10 兼容说明和 typing-extensions
5. 添加 PYTHON_3_10_COMPATIBILITY.md 兼容性说明文档

验证:
- 所有代码无 Python 3.11 特有语法
- 依赖包版本均支持 Python 3.10
- typing-extensions 提供向后兼容支持
2026-07-23 13:58:52 +08:00
Your Name 5b93e4536f add jose 2026-07-23 13:13:31 +08:00
38 changed files with 40454 additions and 209 deletions
+75
View File
@@ -0,0 +1,75 @@
# Python 3.10 兼容性说明
## 概述
本分支 (python3.10) 专门针对 Python 3.10 环境优化,确保所有代码和依赖都能在 Python 3.10 下正常运行。
## 兼容性调整
### 已验证的兼容特性
✅ 所有依赖包版本均支持 Python 3.10
✅ 无 Python 3.11 特有语法(如 `tomllib``asyncio.TaskGroup``typing.Self` 等)
✅ 类型注解完全兼容 Python 3.10
`match` 关键字未用作 Python 3.10 的模式匹配语句(仅用作 `re.match()` 的变量名)
### 重要依赖说明
#### pysnmp 兼容问题解决
**问题**`pysnmp==7.1.15` 存在以下兼容性问题:
- 使用已废弃的 `asyncio.coroutine` 装饰器(Python 3.11 中已移除)
- 与 Python 3.10+ 的 asyncio 不兼容
**解决方案**
使用社区维护的分支 `pysnmp-lextudio==5.0.31`
- 完全向后兼容原有 pysnmp API
- 支持 Python 3.6+
- 积极维护,修复了 asyncio 兼容性问题
- 导入方式完全不变
```python
# 无需修改代码,导入方式保持一致
from pysnmp.hlapi.asyncio import bulkCmd, getCmd, nextCmd
```
#### pyasn1 版本冲突解决
**问题**`pyasn1>=0.5.0` 移除了 `pyasn1.compat.octets` 模块,导致 pysnmp 导入失败。
同时 `python-jose==3.5.0` 强制要求 `pyasn1>=0.5.0`,形成版本冲突。
**解决方案**
- `pyasn1==0.4.8` - 锁定到包含 compat.octets 的版本
- `pyasn1-modules==0.2.8` - 配套版本
- `python-jose[cryptography]==3.3.0` - 兼容 pyasn1 0.4.8 的版本
### 依赖版本要求
- `pysnmp-lextudio==5.0.31` - SNMP 协议兼容替代
- `typing-extensions>=4.5.0` - 提供额外的类型支持
- 所有核心依赖(FastAPI、SQLAlchemy、Pydantic 等)均已验证兼容
## 已知的 Python 3.11+ 特性未使用
项目中未使用以下 Python 3.11+ 特有的功能:
- `tomllib` 标准库(如有需要可使用 `tomli` 第三方库)
- `asyncio.TaskGroup`
- `typing.Self`
- `ExceptionGroup` / `except*`
- `StrEnum` / `IntEnum` 的新特性
## 安装说明
```bash
cd backend
pip install -r requirements.txt
```
## 升级提示
如果未来需要升级到 Python 3.11+,可以:
1. 使用 `tomllib` 替代可能的 TOML 解析需求
2. 考虑使用 `asyncio.TaskGroup` 改进异步代码结构
3. 使用 `typing.Self` 简化类型注解
## 验证
可以使用以下命令验证 Python 版本兼容性:
```bash
python --version # 应为 3.10.x 或 3.11.x
python -c "import sys; assert sys.version_info >= (3, 10), 'Python 3.10+ required'"
# 验证 pysnmp 导入
python -c "from pysnmp.hlapi.asyncio import bulkCmd, getCmd, nextCmd; print('pysnmp OK')"
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -2
View File
@@ -5,6 +5,7 @@ from typing import Optional
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
from app.services.alert_service import AlertService from app.services.alert_service import AlertService
from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list, to_business_iso
router = APIRouter( router = APIRouter(
prefix="/alerts", prefix="/alerts",
@@ -37,7 +38,7 @@ def get_alerts(
total = query.count() total = query.count()
items = query.order_by(Alert.created_at.desc()).offset(skip).limit(limit).all() items = query.order_by(Alert.created_at.desc()).offset(skip).limit(limit).all()
return {"total": total, "items": items} return {"total": total, "items": serialize_dt_list(items)}
@router.get("/{alert_id}", summary="获取告警详情") @router.get("/{alert_id}", summary="获取告警详情")
@@ -47,7 +48,7 @@ def get_alert(alert_id: int, db: Session = Depends(get_db)):
alert = db.query(Alert).filter(Alert.id == alert_id).first() alert = db.query(Alert).filter(Alert.id == alert_id).first()
if not alert: if not alert:
raise HTTPException(status_code=404, detail="告警不存在") raise HTTPException(status_code=404, detail="告警不存在")
return alert return serialize_dt_fields(alert)
@router.post("/{alert_id}/acknowledge", summary="确认告警") @router.post("/{alert_id}/acknowledge", summary="确认告警")
+2 -1
View File
@@ -8,6 +8,7 @@ from app.core.security import get_current_user, require_permission
from app.models.auth import User from app.models.auth import User
from app.models.audit import AuditAction, AuditResource from app.models.audit import AuditAction, AuditResource
from app.services.audit_service import AuditService from app.services.audit_service import AuditService
from app.schemas._tz_util import to_business_iso
router = APIRouter(prefix="/audit", tags=["审计日志"]) router = APIRouter(prefix="/audit", tags=["审计日志"])
@@ -66,7 +67,7 @@ def get_audit_logs(
"request_path": item.request_path, "request_path": item.request_path,
"success": bool(item.success), "success": bool(item.success),
"error_message": item.error_message, "error_message": item.error_message,
"created_at": item.created_at "created_at": to_business_iso(item.created_at)
}) })
return {"total": total, "items": result_items} return {"total": total, "items": result_items}
+32 -37
View File
@@ -7,6 +7,7 @@ from app.core.security import get_current_user
from app.models.auth import User from app.models.auth import User
from app.services.snmp_service import SNMPService from app.services.snmp_service import SNMPService
from app.services.audit_service import AuditService, AuditAction from app.services.audit_service import AuditService, AuditAction
from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list
router = APIRouter( router = APIRouter(
prefix="/snmp", prefix="/snmp",
@@ -34,7 +35,7 @@ def get_snmp_credentials(
query = db.query(SNMPCredential) query = db.query(SNMPCredential)
total = query.count() total = query.count()
items = query.order_by(SNMPCredential.id.desc()).offset(skip).limit(limit).all() items = query.order_by(SNMPCredential.id.desc()).offset(skip).limit(limit).all()
return {"total": total, "items": items} return {"total": total, "items": serialize_dt_list(items)}
@router.get("/credentials/{credential_id}", summary="获取凭据详情") @router.get("/credentials/{credential_id}", summary="获取凭据详情")
@@ -43,7 +44,7 @@ def get_snmp_credential(credential_id: int, db: Session = Depends(get_db)):
credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first() credential = db.query(SNMPCredential).filter(SNMPCredential.id == credential_id).first()
if not credential: if not credential:
raise HTTPException(status_code=404, detail="凭据不存在") raise HTTPException(status_code=404, detail="凭据不存在")
return credential return serialize_dt_fields(credential)
@router.post("/credentials", summary="创建 SNMP 凭据") @router.post("/credentials", summary="创建 SNMP 凭据")
@@ -100,7 +101,7 @@ def create_snmp_credential(
user_agent=u, user_agent=u,
detail={"version": credential.version, "name": credential.name}, detail={"version": credential.version, "name": credential.name},
) )
return credential return serialize_dt_fields(credential)
@router.put("/credentials/{credential_id}", summary="更新 SNMP 凭据") @router.put("/credentials/{credential_id}", summary="更新 SNMP 凭据")
@@ -172,7 +173,7 @@ def update_snmp_credential(
"after": {"name": credential.name, "description": credential.description, "is_active": credential.is_active, "timeout": credential.timeout, "retries": credential.retries}, "after": {"name": credential.name, "description": credential.description, "is_active": credential.is_active, "timeout": credential.timeout, "retries": credential.retries},
}, },
) )
return credential return serialize_dt_fields(credential)
@router.delete("/credentials/{credential_id}", summary="删除 SNMP 凭据") @router.delete("/credentials/{credential_id}", summary="删除 SNMP 凭据")
@@ -243,29 +244,16 @@ def get_network_devices(
serialized = [] serialized = []
for d in items: for d in items:
item = { item = serialize_dt_fields(d)
"id": d.id, # 补充手写的关联字段(datetime 已经由 serialize_dt_fields 序列化)
"name": d.name, item["device_type"] = d.device_type.value if hasattr(d.device_type, 'value') else d.device_type
"description": d.description, item["credential_name"] = cred_map.get(d.snmp_credential_id)
"ip_address": d.ip_address, item["snmp_credential_id"] = d.snmp_credential_id # 显式添加,确保前端能拿到
"port": d.port, item["last_polled_at"] = item.get("last_polled_at") # 已是 ISO 字符串
"device_type": d.device_type.value if hasattr(d.device_type, 'value') else d.device_type, item["last_successful_poll"] = item.get("last_successful_poll")
"vendor": d.vendor, item["arp_poll_interval"] = d.arp_poll_interval
"model": d.model, item["mac_poll_interval"] = d.mac_poll_interval
"firmware_version": d.firmware_version, item["interface_poll_interval"] = d.interface_poll_interval
"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) serialized.append(item)
return {"total": total, "items": serialized} return {"total": total, "items": serialized}
@@ -278,7 +266,7 @@ def get_network_device(device_id: int, db: Session = Depends(get_db)):
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first() device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
if not device: if not device:
raise HTTPException(status_code=404, detail="设备不存在") raise HTTPException(status_code=404, detail="设备不存在")
return device return serialize_dt_fields(device)
@router.post("/devices", summary="创建网络设备") @router.post("/devices", summary="创建网络设备")
@@ -329,7 +317,7 @@ def create_network_device(
user_agent=u, 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}, 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 return serialize_dt_fields(device)
@router.put("/devices/{device_id}", summary="更新网络设备") @router.put("/devices/{device_id}", summary="更新网络设备")
@@ -338,6 +326,7 @@ def update_network_device(
name: Optional[str] = None, name: Optional[str] = None,
ip_address: Optional[str] = None, ip_address: Optional[str] = None,
snmp_credential_id: Optional[int] = None, snmp_credential_id: Optional[int] = None,
clear_snmp_credential: bool = False, # 新增:专门的标志
port: Optional[int] = None, port: Optional[int] = None,
device_type: Optional[str] = None, device_type: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
@@ -363,7 +352,13 @@ def update_network_device(
device.name = name device.name = name
if ip_address: if ip_address:
device.ip_address = ip_address device.ip_address = ip_address
if snmp_credential_id is not None: # 处理 SNMP 凭据:
# - clear_snmp_credential=True → 清除凭据
# - snmp_credential_id > 0 → 设置为该值
# - 否则(都不做任何操作
if clear_snmp_credential:
device.snmp_credential_id = None
elif snmp_credential_id is not None and snmp_credential_id > 0:
device.snmp_credential_id = snmp_credential_id device.snmp_credential_id = snmp_credential_id
if port: if port:
device.port = port device.port = port
@@ -400,7 +395,7 @@ def update_network_device(
}, },
}, },
) )
return device return serialize_dt_fields(device)
@router.delete("/devices/{device_id}", summary="删除网络设备") @router.delete("/devices/{device_id}", summary="删除网络设备")
@@ -442,7 +437,7 @@ def delete_network_device(
# ========== SNMP 操作 ========== # ========== SNMP 操作 ==========
@router.post("/devices/{device_id}/test", summary="测试 SNMP 连接") @router.post("/devices/{device_id}/test", summary="测试 SNMP 连接")
def test_snmp_connection(device_id: int, db: Session = Depends(get_db)): async def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
"""测试设备的 SNMP 连接(不写审计,避免刷屏)""" """测试设备的 SNMP 连接(不写审计,避免刷屏)"""
from app.models.snmp import NetworkDevice from app.models.snmp import NetworkDevice
@@ -453,7 +448,7 @@ def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
if not device.snmp_credential: if not device.snmp_credential:
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据") raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
success, info = SNMPService.test_connection(device, device.snmp_credential) success, info = await SNMPService.test_connection(device, device.snmp_credential)
return { return {
"device_id": device_id, "device_id": device_id,
@@ -464,9 +459,9 @@ def test_snmp_connection(device_id: int, db: Session = Depends(get_db)):
@router.post("/devices/{device_id}/poll", summary="立即轮询设备") @router.post("/devices/{device_id}/poll", summary="立即轮询设备")
def poll_device_now(device_id: int, db: Session = Depends(get_db)): async def poll_device_now(device_id: int, db: Session = Depends(get_db)):
"""立即轮询设备,获取 ARP 表、MAC 地址表、接口信息(不写审计,常规操作)""" """立即轮询设备,获取 ARP 表、MAC 地址表、接口信息(不写审计,常规操作)"""
result = SNMPService.poll_device(db, device_id) result = await SNMPService.poll_device(db, device_id)
if 'error' in result: if 'error' in result:
raise HTTPException(status_code=400, detail=result['error']) raise HTTPException(status_code=400, detail=result['error'])
@@ -475,7 +470,7 @@ def poll_device_now(device_id: int, db: Session = Depends(get_db)):
@router.post("/devices/{device_id}/arp", summary="获取设备 ARP 表") @router.post("/devices/{device_id}/arp", summary="获取设备 ARP 表")
def get_device_arp_table(device_id: int, db: Session = Depends(get_db)): async def get_device_arp_table(device_id: int, db: Session = Depends(get_db)):
"""获取设备的 ARP 表""" """获取设备的 ARP 表"""
from app.models.snmp import NetworkDevice, ARPEntry from app.models.snmp import NetworkDevice, ARPEntry
@@ -486,7 +481,7 @@ def get_device_arp_table(device_id: int, db: Session = Depends(get_db)):
if not device.snmp_credential: if not device.snmp_credential:
raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据") raise HTTPException(status_code=400, detail="设备未配置 SNMP 凭据")
entries = SNMPService.get_arp_table(device, device.snmp_credential, db) entries = await SNMPService.get_arp_table(device, device.snmp_credential, db)
return { return {
"device_id": device_id, "device_id": device_id,
File diff suppressed because it is too large Load Diff
View File
+109
View File
@@ -0,0 +1,109 @@
"""
统一时间格式化工具
背景:MySQL DATETIME 不带时区,backend 用 datetime.utcnow() 写入 UTC naive datetime。
原 schema 直接 .isoformat() 输出,前端拿到无时区字符串后当成 Asia/Shanghai 本地时间显示,
导致所有时间统一少 8 小时。
本工具把任意 datetimenaive 或 aware)一律按 UTC 输出,带 'Z' 后缀。
前端收到 'Z' 后缀字符串后,按 UTC 解析后再 +8 小时显示为 Asia/Shanghai 时间。
也兼容 MySQL 返回 naive 时被错当成 local time 的情况:调用方传入数字或字符串时
不会出错,自动识别。
"""
from datetime import datetime, timezone, timedelta
from typing import Optional, Union
# 业务时区:服务器期望最终用户看到的时区。
# 目前写死 Asia/Shanghai (UTC+8);如以后部署到其他时区,改这里即可。
BUSINESS_TZ = timezone(timedelta(hours=8))
BUSINESS_TZ_NAME = "Asia/Shanghai"
def to_utc_iso(dt: Optional[datetime]) -> Optional[str]:
"""
把 datetime 转成 UTC ISO8601 字符串(带 Z 后缀)。
- aware datetime 调 astimezone(UTC)
- naive datetime 假定为 UTC(与 datetime.utcnow() 写入策略一致)
- None 返回 None
"""
if dt is None:
return None
if dt.tzinfo is None:
# naive 一律视为 UTC(与 datetime.utcnow() 写入策略一致)
dt = dt.replace(tzinfo=timezone.utc)
# 用 '+00:00' 替换成 'Z',更标准的 ISO8601 形式
return dt.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z')
def to_business_iso(dt: Optional[datetime]) -> Optional[str]:
"""
把 datetime 转成业务时区(Asia/Shanghai)的 ISO8601 字符串(带 +08:00 后缀)。
前端拿到后可直接当本地时间显示。
"""
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(BUSINESS_TZ).isoformat()
def utc_str_to_business_str(s: Optional[str]) -> Optional[str]:
"""
'YYYY-MM-DDTHH:MM:SS[.ffffff][Z|+HH:MM]' 字符串按 UTC 解析,
转成业务时区 ISO8601 字符串。
用于后端已经输出 UTC 时,前端(或后端自己)做时区转换的辅助函数。
"""
if not s:
return None
try:
dt = datetime.fromisoformat(s.replace('Z', '+00:00'))
return to_business_iso(dt)
except (ValueError, TypeError):
return s
def now_business_iso() -> str:
"""供后端临时插入用:返回当前业务时区时间"""
return datetime.now(BUSINESS_TZ).isoformat()
def serialize_dt_fields(model, fields: Optional[list] = None) -> dict:
"""
把 ORM model 转成 dictdatetime 字段自动用业务时区 ISO 字符串输出。
用于 audit/snmp/alerts 等内联 dict 序列化的 endpoint,确保返回的
时间字段不会因为裸 .isoformat() 输出而少 8 小时。
用法:
return serialize_dt_fields(alert, ['created_at', 'updated_at'])
return serialize_dt_fields(alert) # 自动检测所有 datetime 字段
"""
from sqlalchemy import inspect as sa_inspect
result = {}
# inspect Class(不是 instance),可以拿 mapper.columns
mapper = sa_inspect(type(model)) if not isinstance(model, type) else sa_inspect(model)
datetime_fields: list = []
if fields is None:
for column in mapper.columns:
col_type = str(column.type).upper()
if 'DATETIME' in col_type or 'TIMESTAMP' in col_type:
datetime_fields.append(column.key)
else:
datetime_fields = fields
for column in mapper.columns:
value = getattr(model, column.key, None)
if column.key in datetime_fields:
result[column.key] = to_business_iso(value)
else:
if hasattr(value, 'value'): # Enum
result[column.key] = value.value
else:
result[column.key] = value
return result
def serialize_dt_list(models, fields: Optional[list] = None) -> list:
"""批量版本:返回每个 model 的 dict 列表"""
return [serialize_dt_fields(m, fields) for m in models] # type: ignore
+28 -2
View File
@@ -1,8 +1,18 @@
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator, field_serializer
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime from datetime import datetime
import ipaddress import ipaddress
from app.models.network import IPStatus, TaskStatus, TaskType from app.models.network import IPStatus, TaskStatus, TaskType
from app.schemas._tz_util import to_business_iso, to_utc_iso
# Pydantic v2 字段序列化器:把 datetime 转成业务时区 ISO 字符串
# 让前端拿到 'YYYY-MM-DDTHH:MM:SS+08:00',可直接当本地时间显示
_datetime_business_serializer = field_serializer(
'datetime',
when_used='always',
check_fields=None, # 重要:应用到所有 datetime 字段
)(lambda dt: to_business_iso(dt))
# ========== 网段相关 Schemas ========== # ========== 网段相关 Schemas ==========
@@ -55,6 +65,11 @@ class Network(NetworkBase):
created_at: datetime created_at: datetime
updated_at: Optional[datetime] updated_at: Optional[datetime]
# Pydantic v2: 用 model_serializer 在序列化整个 model 时把所有 datetime 转成业务时区
@field_serializer('created_at', 'updated_at')
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
return to_business_iso(dt)
class Config: class Config:
from_attributes = True from_attributes = True
@@ -120,6 +135,10 @@ class IPAddress(IPAddressBase):
created_at: datetime created_at: datetime
updated_at: Optional[datetime] updated_at: Optional[datetime]
@field_serializer('last_seen', 'first_seen', 'created_at', 'updated_at')
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
return to_business_iso(dt)
class Config: class Config:
from_attributes = True from_attributes = True
@@ -155,6 +174,13 @@ class ScanTask(BaseModel):
error_message: Optional[str] error_message: Optional[str]
created_at: datetime created_at: datetime
@field_serializer(
'started_at', 'completed_at', 'created_at',
check_fields=None,
)
def _tz_serializers(self, dt: Optional[datetime]) -> Optional[str]:
return to_business_iso(dt)
class Config: class Config:
from_attributes = True from_attributes = True
@@ -170,4 +196,4 @@ class ScanResult(BaseModel):
status: str status: str
mac_address: Optional[str] = None mac_address: Optional[str] = None
hostname: Optional[str] = None hostname: Optional[str] = None
response_time: Optional[float] = None response_time: Optional[float] = None
+49 -4
View File
@@ -7,6 +7,7 @@ import socket
import ipaddress import ipaddress
import re import re
import logging import logging
import os
try: try:
from mac_vendor_lookup import MacLookup, VendorNotFoundError from mac_vendor_lookup import MacLookup, VendorNotFoundError
@@ -20,6 +21,40 @@ from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# OUI 数据库文件路径(相对于项目根)
_OUI_DB_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'oui.txt')
def _load_oui_database() -> Optional[Dict[str, str]]:
"""从本地文件加载 OUI 数据库,返回 {prefix: vendor} 字典"""
if not os.path.exists(_OUI_DB_FILE):
logger.warning(f"OUI 数据库文件不存在: {_OUI_DB_FILE}")
return None
try:
db = {}
with open(_OUI_DB_FILE, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if ':' in line:
prefix, vendor = line.split(':', 1)
db[prefix] = vendor
logger.info(f"已加载 OUI 数据库: {len(db)} 条记录, 文件: {_OUI_DB_FILE}")
return db
except Exception as e:
logger.error(f"加载 OUI 数据库失败: {e}")
return None
# 全局 OUI 数据库(延迟加载)
_OUI_DB = None
def _get_oui_db() -> Optional[Dict[str, str]]:
global _OUI_DB
if _OUI_DB is None:
_OUI_DB = _load_oui_database()
return _OUI_DB
class EnhancedScanService: class EnhancedScanService:
"""增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别、NetBIOS主机名发现""" """增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别、NetBIOS主机名发现"""
@@ -45,13 +80,23 @@ class EnhancedScanService:
if len(mac) < 8: if len(mac) < 8:
return None return None
# 使用 mac_vendor_lookup 库(IEEE OUI 官方数据库 # 提取 OUI(前 6 位十六进制 = AA:BB:CC
oui = mac[:8] # "AA:BB:CC"
# 1️⃣ 优先使用本地 OUI 数据库(免网络、高性能)
db = _get_oui_db()
if db:
# 数据库 key 是 AABBCC 格式(无冒号)
oui_key = oui.replace(':', '')
vendor = db.get(oui_key)
if vendor:
return vendor
# 2️⃣ 回退:mac_vendor_lookup 在线库
if MAC_LOOKUP_AVAILABLE: if MAC_LOOKUP_AVAILABLE:
lookup = EnhancedScanService._get_mac_lookup() lookup = EnhancedScanService._get_mac_lookup()
if lookup is not None: if lookup is not None:
try: try:
# 提取 OUI(前 3 字节)
oui = mac[:8] # "AA:BB:CC"
vendor = lookup.lookup(oui) vendor = lookup.lookup(oui)
if vendor: if vendor:
return vendor return vendor
@@ -60,7 +105,7 @@ class EnhancedScanService:
except Exception: except Exception:
pass pass
# 回退:硬编码常用厂商映射 # 3️⃣ 回退:硬编码常用厂商映射
return EnhancedScanService._legacy_oui_lookup(mac) return EnhancedScanService._legacy_oui_lookup(mac)
# 硬编码回退映射(mac_vendor_lookup 不可用时使用) # 硬编码回退映射(mac_vendor_lookup 不可用时使用)
+2 -2
View File
@@ -14,9 +14,9 @@ class NetworkService:
@staticmethod @staticmethod
def calculate_total_ips(cidr: str) -> int: def calculate_total_ips(cidr: str) -> int:
"""计算网段的总IP数量""" """计算网段中实际分配的IP数量(排除网络地址和广播地址)"""
network = ipaddress.ip_network(cidr, strict=False) network = ipaddress.ip_network(cidr, strict=False)
return network.num_addresses return network.num_addresses - 2 if network.num_addresses > 2 else network.num_addresses
@staticmethod @staticmethod
def get_network_addresses(cidr: str) -> List[str]: def get_network_addresses(cidr: str) -> List[str]:
+119 -118
View File
@@ -3,8 +3,9 @@ from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
import ipaddress import ipaddress
import logging import logging
import asyncio
from pysnmp.hlapi import ( from pysnmp.hlapi.asyncio import (
SnmpEngine, CommunityData, UsmUserData, SnmpEngine, CommunityData, UsmUserData,
UdpTransportTarget, ContextData, UdpTransportTarget, ContextData,
ObjectType, ObjectIdentity, ObjectType, ObjectIdentity,
@@ -117,7 +118,7 @@ class SNMPService:
return None return None
@staticmethod @staticmethod
def test_connection(device: NetworkDevice, credential: SNMPCredential) -> Tuple[bool, Dict[str, Any]]: async def test_connection(device: NetworkDevice, credential: SNMPCredential) -> Tuple[bool, Dict[str, Any]]:
""" """
测试 SNMP 连接 测试 SNMP 连接
返回: (成功, 设备信息字典) 返回: (成功, 设备信息字典)
@@ -131,11 +132,11 @@ class SNMPService:
retries=credential.retries retries=credential.retries
) )
errorIndication, errorStatus, errorIndex, varBinds = next( errorIndication, errorStatus, errorIndex, varBinds = await getCmd(
getCmd(SnmpEngine(), auth_data, transport, ContextData(), SnmpEngine(), auth_data, transport, ContextData(),
ObjectType(ObjectIdentity(SNMPService.OID_SYS_DESCR)), ObjectType(ObjectIdentity(SNMPService.OID_SYS_DESCR)),
ObjectType(ObjectIdentity(SNMPService.OID_SYS_NAME)), ObjectType(ObjectIdentity(SNMPService.OID_SYS_NAME)),
ObjectType(ObjectIdentity(SNMPService.OID_SYS_LOCATION))) ObjectType(ObjectIdentity(SNMPService.OID_SYS_LOCATION))
) )
if errorIndication: if errorIndication:
@@ -162,7 +163,7 @@ class SNMPService:
return False, {'error': str(e)} return False, {'error': str(e)}
@staticmethod @staticmethod
def get_arp_table(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]: async def get_arp_table(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
""" """
获取 ARP 表 (IP -> MAC 映射) 获取 ARP 表 (IP -> MAC 映射)
""" """
@@ -178,61 +179,60 @@ class SNMPService:
) )
# 使用 bulkCmd 获取 ARP 表 # 使用 bulkCmd 获取 ARP 表
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd( errorIndication, errorStatus, errorIndex, varBindTable = await bulkCmd(
SnmpEngine(), auth_data, transport, ContextData(), SnmpEngine(), auth_data, transport, ContextData(),
0, 50, # nonRepeaters, maxRepetitions 0, 50, # nonRepeaters, maxRepetitions
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_PHYS_ADDRESS)), 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_NET_ADDRESS)),
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_IF_INDEX)), ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_IF_INDEX)),
lexicographicMode=False lexicographicMode=False
): )
if errorIndication: if errorIndication:
logger.error(f"SNMP 错误: {errorIndication}") logger.error(f"SNMP 错误: {errorIndication}")
break elif errorStatus:
logger.error(f"SNMP 错误: {errorStatus}")
else:
# varBindTable 是列表的列表,每行是一条 SNMP 响应记录
for varBinds in varBindTable:
# 解析结果
arp_data = {}
for varBind in varBinds:
oid = str(varBind[0])
value = varBind[1]
if errorStatus: # 提取 IP 地址 (OID 后缀: ifIndex.IP)
logger.error(f"SNMP 错误: {errorStatus}") parts = oid.split('.')
break 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:
arp_data = {} mac = SNMPService._normalize_mac(value)
for varBind in varBinds: if mac:
oid = str(varBind[0]) if ip_address not in arp_data:
value = varBind[1] 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
# 提取 IP 地址 (OID 后缀: ifIndex.IP) # 转换为列表
parts = oid.split('.') now = datetime.utcnow()
if len(parts) >= 4: for ip, data in arp_data.items():
# 从 OID 中提取 IP 地址 if 'mac_address' in data:
ip_parts = parts[-4:] entry = {
ip_address = '.'.join(ip_parts) 'ip_address': ip,
'mac_address': data.get('mac_address'),
if SNMPService.OID_IP_NET_TO_MEDIA_PHYS_ADDRESS in oid: 'interface': str(data.get('if_index', '')),
mac = SNMPService._normalize_mac(value) 'device_id': device.id,
if mac: 'last_seen': now.isoformat(),
if ip_address not in arp_data: 'discovered_at': now.isoformat()
arp_data[ip_address] = {} }
arp_data[ip_address]['mac_address'] = mac arp_entries.append(entry)
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
# 转换为列表
now = datetime.utcnow()
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,
'last_seen': now.isoformat(),
'discovered_at': now.isoformat()
}
arp_entries.append(entry)
# 保存到数据库 # 保存到数据库
for entry_data in arp_entries: for entry_data in arp_entries:
@@ -258,16 +258,18 @@ class SNMPService:
db.commit() db.commit()
# 更新 IP 资产台账中的 MAC 地址 # 更新 IP 资产台账中的 MAC 地址和厂商信息
for entry in arp_entries: for entry in arp_entries:
from app.models.network import IPAddress as IPAddressModel from app.models.network import IPAddress as IPAddressModel
ip_addr = db.query(IPAddressModel).filter( ip_addr = db.query(IPAddressModel).filter(
IPAddressModel.ip_address == entry['ip_address'] IPAddressModel.ip_address == entry['ip_address']
).first() ).first()
if ip_addr and not ip_addr.mac_address: if ip_addr:
ip_addr.mac_address = entry['mac_address'] if not ip_addr.mac_address:
# 识别厂商 ip_addr.mac_address = entry['mac_address']
ip_addr.vendor = EnhancedScanService.get_mac_vendor(entry['mac_address']) # 始终尝试补全厂商(有 MAC 但没厂商时也需要)
if not ip_addr.vendor and ip_addr.mac_address:
ip_addr.vendor = EnhancedScanService.get_mac_vendor(ip_addr.mac_address)
db.commit() db.commit()
device.last_successful_poll = now device.last_successful_poll = now
@@ -281,7 +283,7 @@ class SNMPService:
return arp_entries return arp_entries
@staticmethod @staticmethod
def get_mac_address_table(device: NetworkDevice, credential: SNMPCredential, db: Session, vlan_id: Optional[int] = None) -> List[Dict[str, Any]]: async def get_mac_address_table(device: NetworkDevice, credential: SNMPCredential, db: Session, vlan_id: Optional[int] = None) -> List[Dict[str, Any]]:
""" """
获取 MAC 地址表 (MAC -> 端口 映射) 获取 MAC 地址表 (MAC -> 端口 映射)
""" """
@@ -297,39 +299,39 @@ class SNMPService:
) )
# 获取 MAC 地址表 # 获取 MAC 地址表
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd( errorIndication, errorStatus, errorIndex, varBindTable = await bulkCmd(
SnmpEngine(), auth_data, transport, ContextData(), SnmpEngine(), auth_data, transport, ContextData(),
0, 100, 0, 100,
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_PORT)), ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_PORT)),
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_STATUS)), ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_STATUS)),
lexicographicMode=False lexicographicMode=False
): )
if errorIndication: if errorIndication:
break logger.error(f"获取 MAC 地址表失败: {errorIndication}")
elif errorStatus:
logger.error(f"获取 MAC 地址表失败: {errorStatus}")
else:
for varBinds in varBindTable:
for varBind in varBinds:
oid = str(varBind[0])
value = varBind[1]
if errorStatus: # OID 后缀是 MAC 地址: .a.b.c.d.e.f
break parts = oid.split('.')
if len(parts) >= 6:
mac_parts = parts[-6:]
mac_address = ':'.join(f'{int(p):02X}' for p in mac_parts)
for varBind in varBinds: if SNMPService.OID_DOT1D_TP_FDB_PORT in oid:
oid = str(varBind[0]) port = int(value) if isinstance(value, Integer32) else None
value = varBind[1]
# OID 后缀是 MAC 地址: .a.b.c.d.e.f entry = {
parts = oid.split('.') 'mac_address': mac_address,
if len(parts) >= 6: 'port_number': port,
mac_parts = parts[-6:] 'device_id': device.id,
mac_address = ':'.join(f'{int(p):02X}' for p in mac_parts) 'vlan_id': vlan_id
}
if SNMPService.OID_DOT1D_TP_FDB_PORT in oid: mac_entries.append(entry)
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() now = datetime.utcnow()
@@ -361,7 +363,7 @@ class SNMPService:
return mac_entries return mac_entries
@staticmethod @staticmethod
def get_interfaces(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]: async def get_interfaces(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
""" """
获取设备接口信息 获取设备接口信息
""" """
@@ -379,7 +381,7 @@ class SNMPService:
# 获取接口信息 # 获取接口信息
interface_data = {} interface_data = {}
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd( errorIndication, errorStatus, errorIndex, varBindTable = await bulkCmd(
SnmpEngine(), auth_data, transport, ContextData(), SnmpEngine(), auth_data, transport, ContextData(),
0, 100, 0, 100,
ObjectType(ObjectIdentity(SNMPService.OID_IF_DESCR)), ObjectType(ObjectIdentity(SNMPService.OID_IF_DESCR)),
@@ -390,35 +392,34 @@ class SNMPService:
ObjectType(ObjectIdentity(SNMPService.OID_IF_ADMIN_STATUS)), ObjectType(ObjectIdentity(SNMPService.OID_IF_ADMIN_STATUS)),
ObjectType(ObjectIdentity(SNMPService.OID_IF_OPER_STATUS)), ObjectType(ObjectIdentity(SNMPService.OID_IF_OPER_STATUS)),
lexicographicMode=False lexicographicMode=False
): )
if errorIndication or errorStatus: if not errorIndication and not errorStatus:
break for varBinds in varBindTable:
for varBind in varBinds:
oid = str(varBind[0])
value = varBind[1]
parts = oid.split('.')
if_index = parts[-1]
for varBind in varBinds: if if_index not in interface_data:
oid = str(varBind[0]) interface_data[if_index] = {'if_index': int(if_index)}
value = varBind[1]
parts = oid.split('.')
if_index = parts[-1]
if if_index not in interface_data: if SNMPService.OID_IF_DESCR in oid:
interface_data[if_index] = {'if_index': int(if_index)} interface_data[if_index]['if_descr'] = value.prettyPrint()
elif SNMPService.OID_IF_TYPE in oid:
if SNMPService.OID_IF_DESCR in oid: interface_data[if_index]['if_type'] = str(value)
interface_data[if_index]['if_descr'] = value.prettyPrint() elif SNMPService.OID_IF_MTU in oid:
elif SNMPService.OID_IF_TYPE in oid: interface_data[if_index]['if_mtu'] = int(value) if isinstance(value, Integer32) else None
interface_data[if_index]['if_type'] = str(value) elif SNMPService.OID_IF_SPEED in oid:
elif SNMPService.OID_IF_MTU in oid: interface_data[if_index]['if_speed'] = int(value) if isinstance(value, (Integer32, Gauge32)) else None
interface_data[if_index]['if_mtu'] = int(value) if isinstance(value, Integer32) else None elif SNMPService.OID_IF_PHYS_ADDRESS in oid:
elif SNMPService.OID_IF_SPEED in oid: interface_data[if_index]['if_phys_address'] = SNMPService._normalize_mac(value)
interface_data[if_index]['if_speed'] = int(value) if isinstance(value, (Integer32, Gauge32)) else None elif SNMPService.OID_IF_ADMIN_STATUS in oid:
elif SNMPService.OID_IF_PHYS_ADDRESS in oid: status_map = {1: 'up', 2: 'down', 3: 'testing'}
interface_data[if_index]['if_phys_address'] = SNMPService._normalize_mac(value) interface_data[if_index]['if_admin_status'] = status_map.get(int(value), 'unknown')
elif SNMPService.OID_IF_ADMIN_STATUS in oid: elif SNMPService.OID_IF_OPER_STATUS in oid:
status_map = {1: 'up', 2: 'down', 3: 'testing'} status_map = {1: 'up', 2: 'down', 3: 'testing'}
interface_data[if_index]['if_admin_status'] = status_map.get(int(value), 'unknown') interface_data[if_index]['if_oper_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() now = datetime.utcnow()
@@ -485,7 +486,7 @@ class SNMPService:
return device return device
@staticmethod @staticmethod
def poll_device(db: Session, device_id: int) -> Dict[str, Any]: async def poll_device(db: Session, device_id: int) -> Dict[str, Any]:
"""轮询设备,获取所有信息""" """轮询设备,获取所有信息"""
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first() device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
if not device: if not device:
@@ -505,17 +506,17 @@ class SNMPService:
} }
# 获取 ARP 表 # 获取 ARP 表
arp_entries = SNMPService.get_arp_table(device, credential, db) arp_entries = await SNMPService.get_arp_table(device, credential, db)
result['arp_entries'] = arp_entries result['arp_entries'] = arp_entries
result['arp_count'] = len(arp_entries) result['arp_count'] = len(arp_entries)
# 获取 MAC 地址表 # 获取 MAC 地址表
mac_entries = SNMPService.get_mac_address_table(device, credential, db) mac_entries = await SNMPService.get_mac_address_table(device, credential, db)
result['mac_entries'] = mac_entries result['mac_entries'] = mac_entries
result['mac_count'] = len(mac_entries) result['mac_count'] = len(mac_entries)
# 获取接口信息 # 获取接口信息
interfaces = SNMPService.get_interfaces(device, credential, db) interfaces = await SNMPService.get_interfaces(device, credential, db)
result['interfaces'] = interfaces result['interfaces'] = interfaces
result['interface_count'] = len(interfaces) result['interface_count'] = len(interfaces)
+12 -1
View File
@@ -1,3 +1,4 @@
# Python 3.10+ 兼容版本
fastapi==0.110.0 fastapi==0.110.0
uvicorn[standard]==0.27.1 uvicorn[standard]==0.27.1
sqlalchemy==2.0.28 sqlalchemy==2.0.28
@@ -9,8 +10,18 @@ celery==5.3.6
redis==5.0.3 redis==5.0.3
python-multipart==0.0.9 python-multipart==0.0.9
alembic==1.13.1 alembic==1.13.1
pysnmp==7.1.15 # 注意: pysnmp 7.x 与 Python 3.10+/asyncio 存在兼容性问题
# 使用 lextudio 维护的分支(向后兼容,支持 Python 3.6+
pysnmp-lextudio==5.0.31
# pyasn1 0.5.0+ 移除了 compat.octets 模块,与 pysnmp 不兼容
pyasn1==0.4.8
pyasn1-modules==0.2.8
scapy==2.5.0 scapy==2.5.0
python-dotenv==1.0.1 python-dotenv==1.0.1
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
httpx==0.27.0 httpx==0.27.0
# python-jose 3.5.0 需要 pyasn1>=0.5.0,与 pysnmp 冲突
python-jose[cryptography]==3.3.0
bcrypt==3.2.2
# typing-extensions 提供 Python 3.10+ 的向后兼容支持
typing-extensions>=4.5.0
+39
View File
@@ -0,0 +1,39 @@
"""
修复已有网段的 total_ips 字段:将 num_addresses 改为 hosts 数量(排除网络地址和广播地址)
用法: cd /root/ipam/backend && source venv/bin/activate && python scripts/fix_network_total_ips.py
"""
import sys
import os
# 确保能找到 app 模块
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core.database import SessionLocal
from app.models.network import Network
from app.services.network_service import NetworkService
def fix_total_ips():
db = SessionLocal()
try:
networks = db.query(Network).all()
fixed = 0
for net in networks:
correct = NetworkService.calculate_total_ips(net.cidr)
actual_hosts = len(list(NetworkService.get_network_addresses(net.cidr)))
if net.total_ips != correct:
print(f" [{net.cidr:20s}] {net.total_ips:>5} -> {correct:>5} (hosts={actual_hosts})")
net.total_ips = correct
fixed += 1
db.commit()
print(f"\n✅ 已修复 {fixed} 个网段")
except Exception as e:
db.rollback()
print(f"❌ 错误: {e}")
finally:
db.close()
if __name__ == "__main__":
fix_total_ips()
+4 -3
View File
@@ -4,9 +4,10 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "node node_modules/vite/bin/vite.js",
"build": "vite build", "dev:clean": "rm -rf node_modules/.vite && node node_modules/vite/bin/vite.js --force",
"preview": "vite preview" "build": "node node_modules/vite/bin/vite.js build",
"preview": "node node_modules/vite/bin/vite.js preview"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
+124
View File
@@ -0,0 +1,124 @@
/**
* 统一时间格式化工具
*
* 背景:后端用 datetime.utcnow() 写入 UTC naive datetime。
* 修复后端后,schema 输出会带 +08:00 或 ZAsia/Shanghai 或 UTC)时区标签。
* 本 helper
* 1. 收到的字符串如果带时区标签(Z 或 +HH:MM),按字面解析
* 2. 如果无时区标签,假定为 UTC 解析(兼容老 endpoint 内联 dict 序列化)
* 3. 转 Asia/Shanghai (+08:00) 显示
* 4. 显示成 'YYYY-MM-DD HH:MM:SS' 字符串
*/
const BUSINESS_TZ = 'Asia/Shanghai'
export function parseToBusiness (input) {
if (input == null || input === '') return null
let d
if (input instanceof Date) {
d = input
} else if (typeof input === 'number') {
d = new Date(input)
} else {
// 字符串:优先按 ISO 解析(含 Z / +HH:MM 则按对应时区)
// JS 原生 new Date() 对 'YYYY-MM-DD HH:MM:SS'(无 T,无时区)按本地时区解析,
// 对 'YYYY-MM-DDTHH:MM:SSZ' 等标准 ISO 8601 则按 UTC 解析。
const s = String(input).trim()
// 后端 schema 修复后输出 'YYYY-MM-DDTHH:MM:SS+08:00'aware),
// 老 endpoint 内联 dict 输出 'YYYY-MM-DD HH:MM:SS'naive UTC)。
// JS Date 无法区分后者的'naive UTC'和'naive 本地时间'。
// 但本项目所有 datetime 后端都是 datetime.utcnow() 写的,
// 所以 naive 也按 UTC 处理。
d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z')
}
if (isNaN(d.getTime())) return null
return d
}
/**
* 把任意 datetime 输入格式化成 Asia/Shanghai 时区的 'YYYY-MM-DD HH:MM:SS'。
* 不做时区转换的纯字符串渲染请用 formatUtc。
*/
export function formatDateTime (input) {
if (input == null || input === '') return '-'
const d = parseToBusiness(input)
if (!d) return '-'
const opts = {
timeZone: BUSINESS_TZ,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
}
try {
return new Intl.DateTimeFormat('zh-CN', opts).format(d)
// zh-CN 格式默认是 "2026/07/23 14:31:19",替换成 "2026-07-23 14:31:19"
.replace(/\//g, '-')
} catch {
// 浏览器不支持 Intl 时区时回退:用本地时区手动格式化
return formatLocal(d)
}
}
/**
* 仅显示日期 'YYYY-MM-DD'。
*/
export function formatDate (input) {
if (input == null || input === '') return '-'
const d = parseToBusiness(input)
if (!d) return '-'
try {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: BUSINESS_TZ,
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d).replace(/\//g, '-')
} catch {
return formatLocal(d).substring(0, 10)
}
}
/**
* 仅显示时间 'HH:MM' 或 'HH:MM:SS'(带 seconds 参数控制)。
*/
export function formatTime (input, { seconds = false } = {}) {
if (input == null || input === '') return '-'
const d = parseToBusiness(input)
if (!d) return '-'
const opts = {
timeZone: BUSINESS_TZ,
hour: '2-digit', minute: '2-digit', hour12: false,
}
if (seconds) opts.second = '2-digit'
try {
return new Intl.DateTimeFormat('zh-CN', opts).format(d)
} catch {
return formatLocal(d).substring(11)
}
}
/**
* 用户友好的相对时间描述("3 分钟前")。
*/
export function formatRelative (input) {
if (input == null || input === '') return '-'
const d = parseToBusiness(input)
if (!d) return '-'
const diff = (Date.now() - d.getTime()) / 1000
if (diff < 60) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
if (diff < 604800) return `${Math.floor(diff / 86400)} 天前`
return formatDateTime(input)
}
// 内部:浏览器不支持时区时回退到本地格式化(业务时区手动转)
function formatLocal (d) {
// 用本地方法拿到 YYYY-MM-DD HH:MM:SS(按浏览器本地时区,可能错 8 小时但作为兜底)
const pad = n => String(n).padStart(2, '0')
const y = d.getFullYear()
const mo = pad(d.getMonth() + 1)
const da = pad(d.getDate())
const h = pad(d.getHours())
const mi = pad(d.getMinutes())
const s = pad(d.getSeconds())
return `${y}-${mo}-${da} ${h}:${mi}:${s}`
}
+4 -6
View File
@@ -141,7 +141,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="时间" width="180"> <el-table-column prop="created_at" label="时间" width="180">
<template #default="scope"> <template #default="scope">
{{ formatDate(scope.row.created_at) }} {{ formatDateTime(scope.row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right"> <el-table-column label="操作" width="200" align="center" fixed="right">
@@ -194,7 +194,7 @@
<el-table-column prop="owner" label="所有者" width="120" /> <el-table-column prop="owner" label="所有者" width="120" />
<el-table-column prop="created_at" label="添加时间" width="180"> <el-table-column prop="created_at" label="添加时间" width="180">
<template #default="scope"> <template #default="scope">
{{ formatDate(scope.row.created_at) }} {{ formatDateTime(scope.row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="100" align="center"> <el-table-column label="操作" width="100" align="center">
@@ -238,6 +238,7 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { alertApi } from '@/api' import { alertApi } from '@/api'
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
const loading = ref(false) const loading = ref(false)
const detecting = ref(false) const detecting = ref(false)
@@ -460,10 +461,7 @@ const getAlertTypeText = (type) => {
return texts[type] || type return texts[type] || type
} }
const formatDate = (date) => { // 旧 formatDate 已由 @/utils/datetime 统一替代
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
onMounted(() => { onMounted(() => {
loadStats() loadStats()
+3 -5
View File
@@ -107,7 +107,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="last_seen" label="最后发现" width="170"> <el-table-column prop="last_seen" label="最后发现" width="170">
<template #default="scope"> <template #default="scope">
{{ scope.row.last_seen ? formatDate(scope.row.last_seen) : '-' }} {{ scope.row.last_seen ? formatDateTime(scope.row.last_seen) : '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right"> <el-table-column label="操作" width="180" align="center" fixed="right">
@@ -174,6 +174,7 @@ import { ref, reactive, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { ipApi, networkApi, scanApi } from '@/api' import { ipApi, networkApi, scanApi } from '@/api'
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
const route = useRoute() const route = useRoute()
@@ -350,10 +351,7 @@ const getStatusText = (status) => {
return texts[status] || status return texts[status] || status
} }
const formatDate = (date) => { // formatDate @/utils/datetime
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
onMounted(() => { onMounted(() => {
loadNetworks() loadNetworks()
+3 -5
View File
@@ -58,7 +58,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180"> <el-table-column prop="created_at" label="创建时间" width="180">
<template #default="scope"> <template #default="scope">
{{ formatDate(scope.row.created_at) }} {{ formatDateTime(scope.row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="280" align="center" fixed="right"> <el-table-column label="操作" width="280" align="center" fixed="right">
@@ -139,6 +139,7 @@ import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { networkApi, scanApi } from '@/api' import { networkApi, scanApi } from '@/api'
import { formatDateTime, formatDate } from '@/utils/datetime'
const router = useRouter() const router = useRouter()
@@ -304,10 +305,7 @@ const getGroupTagType = (group) => {
return types[group] || 'info' return types[group] || 'info'
} }
const formatDate = (date) => { // formatDate @/utils/datetime
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
onMounted(() => { onMounted(() => {
loadNetworks() loadNetworks()
+12 -8
View File
@@ -39,7 +39,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="last_polled_at" label="最后轮询" width="180"> <el-table-column prop="last_polled_at" label="最后轮询" width="180">
<template #default="scope"> <template #default="scope">
{{ scope.row.last_polled_at ? formatDate(scope.row.last_polled_at) : '-' }} {{ scope.row.last_polled_at ? formatDateTime(scope.row.last_polled_at) : '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="300" align="center" fixed="right"> <el-table-column label="操作" width="300" align="center" fixed="right">
@@ -98,7 +98,7 @@
<el-table-column prop="retries" label="重试次数" width="100" align="center" /> <el-table-column prop="retries" label="重试次数" width="100" align="center" />
<el-table-column prop="created_at" label="创建时间" width="180"> <el-table-column prop="created_at" label="创建时间" width="180">
<template #default="scope"> <template #default="scope">
{{ formatDate(scope.row.created_at) }} {{ formatDateTime(scope.row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="150" align="center" fixed="right"> <el-table-column label="操作" width="150" align="center" fixed="right">
@@ -152,7 +152,7 @@
<el-table-column prop="interface" label="接口" width="120" /> <el-table-column prop="interface" label="接口" width="120" />
<el-table-column prop="last_seen" label="最后发现" width="180"> <el-table-column prop="last_seen" label="最后发现" width="180">
<template #default="scope"> <template #default="scope">
{{ formatDate(scope.row.last_seen) }} {{ formatDateTime(scope.row.last_seen) }}
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -275,6 +275,7 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { snmpApi } from '@/api' import { snmpApi } from '@/api'
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
const activeTab = ref('devices') const activeTab = ref('devices')
const loading = ref(false) const loading = ref(false)
@@ -397,7 +398,13 @@ const saveDevice = async () => {
submitting.value = true submitting.value = true
try { try {
if (isDeviceEdit.value) { if (isDeviceEdit.value) {
await snmpApi.updateDevice(deviceForm.id, deviceForm) // snmp_credential_id=null clear_snmp_credential=true
const updateData = { ...deviceForm }
if (updateData.snmp_credential_id === null) {
delete updateData.snmp_credential_id
updateData.clear_snmp_credential = true
}
await snmpApi.updateDevice(deviceForm.id, updateData)
ElMessage.success('更新成功') ElMessage.success('更新成功')
} else { } else {
await snmpApi.createDevice(deviceForm) await snmpApi.createDevice(deviceForm)
@@ -530,10 +537,7 @@ const deleteCredential = async (id) => {
} }
} }
const formatDate = (date) => { // formatDate @/utils/datetime
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
onMounted(() => { onMounted(() => {
loading.value = true loading.value = true
+3 -10
View File
@@ -58,7 +58,7 @@
</el-table-column> </el-table-column>
<el-table-column label="最后登录" width="170"> <el-table-column label="最后登录" width="170">
<template #default="scope"> <template #default="scope">
{{ scope.row.last_login_at ? formatDate(scope.row.last_login_at) : '-' }} {{ scope.row.last_login_at ? formatDateTime(scope.row.last_login_at) : '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="280" align="center" fixed="right"> <el-table-column label="操作" width="280" align="center" fixed="right">
@@ -162,6 +162,7 @@ import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh } from '@element-plus/icons-vue' import { Plus, Search, Refresh } from '@element-plus/icons-vue'
import { userApi } from '@/api/auth' import { userApi } from '@/api/auth'
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
import { authStore } from '@/utils/auth' import { authStore } from '@/utils/auth'
const loading = ref(false) const loading = ref(false)
@@ -206,15 +207,7 @@ function roleTagType(r) { return ROLE_MAP[r]?.tagType || '' }
function statusLabel(s) { return STATUS_MAP[s]?.label || s || '-' } function statusLabel(s) { return STATUS_MAP[s]?.label || s || '-' }
function statusTagType(s) { return STATUS_MAP[s]?.tagType || '' } function statusTagType(s) { return STATUS_MAP[s]?.tagType || '' }
function formatDate(d) { // formatDate @/utils/datetime
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() { async function loadUsers() {
loading.value = true loading.value = true
+46 -5
View File
@@ -138,15 +138,41 @@ start_frontend() {
cd $FRONTEND_DIR cd $FRONTEND_DIR
# 检查 node_modules # 检查 node_modules 及关键依赖是否完整
if [ ! -d "node_modules" ]; then if [ ! -d "node_modules" ] || [ ! -f "node_modules/element-plus/es/index.mjs" ]; then
echo -e "${YELLOW}安装前端依赖...${NC}" echo -e "${YELLOW}安装前端依赖...${NC}"
npm install npm install
fi fi
# 检查 OUI 数据库文件
if [ ! -f "$BACKEND_DIR/app/data/oui.txt" ]; then
echo -e "${YELLOW}OUI 数据库不存在,尝试从 IEEE 下载...${NC}"
cd $BACKEND_DIR
source venv/bin/activate
python3 -c "
from mac_vendor_lookup import MacLookup
import os
os.makedirs('app/data', exist_ok=True)
lookup = MacLookup()
lookup.update_vendors()
# 复制到项目目录
import shutil
src = os.path.expanduser('~/.cache/mac-vendors.txt')
if os.path.exists(src):
shutil.copy(src, 'app/data/oui.txt')
print('OUI 数据库下载完成')
" 2>/dev/null || echo -e "${YELLOW}OUI 数据库下载失败(网络问题),使用内置硬编码回退${NC}"
fi
# 检查端口并清理 # 检查端口并清理
kill_port $FRONTEND_PORT kill_port $FRONTEND_PORT
# 清理 Vite 缓存,避免 element-plus exports 解析失败
if [ -d "node_modules/.vite" ]; then
echo -e "${YELLOW}清理 Vite 缓存...${NC}"
rm -rf node_modules/.vite
fi
# 后台启动前端 # 后台启动前端
nohup npm run dev -- --host 0.0.0.0 --port $FRONTEND_PORT > /tmp/ipam-frontend.log 2>&1 & nohup npm run dev -- --host 0.0.0.0 --port $FRONTEND_PORT > /tmp/ipam-frontend.log 2>&1 &
FRONTEND_PID=$! FRONTEND_PID=$!
@@ -159,9 +185,24 @@ start_frontend() {
echo " 日志: /tmp/ipam-frontend.log" echo " 日志: /tmp/ipam-frontend.log"
return 0 return 0
else else
echo -e "${RED}❌ 前端服务启动失败${NC}" # 如果启动失败,尝试清缓存重试一次
echo " 查看日志: tail -50 /tmp/ipam-frontend.log" echo -e "${YELLOW}首次启动失败,尝试清理 Vite 缓存重试...${NC}"
return 1 kill -9 $FRONTEND_PID 2>/dev/null
sleep 1
rm -rf node_modules/.vite
nohup npm run dev -- --host 0.0.0.0 --port $FRONTEND_PORT > /tmp/ipam-frontend.log 2>&1 &
FRONTEND_PID=$!
sleep 12
if curl -s http://localhost:$FRONTEND_PORT > /dev/null; then
echo -e "${GREEN}✅ 前端服务启动成功 (端口 $FRONTEND_PORT)${NC}"
echo " PID: $FRONTEND_PID"
echo " 日志: /tmp/ipam-frontend.log"
return 0
else
echo -e "${RED}❌ 前端服务启动失败${NC}"
echo " 查看日志: tail -50 /tmp/ipam-frontend.log"
return 1
fi
fi fi
echo "" echo ""
} }