21 Commits

Author SHA1 Message Date
Your Name 365ab73e7d feat(alerts): 飞书通知支持 Web 界面配置(数据库存储+热更新)
1. 新增 notification_config 模型,飞书配置存数据库(单行id=1)
2. feishu_service 改为从数据库读配置,支持热更新,Web保存即生效
3. alerts.py 新增接口:
   - GET /alerts/notify/feishu/config 读配置(secret只返回是否已设)
   - PUT /alerts/notify/feishu/config 保存配置+热更新
   (status/test 同步改为走数据库配置)
4. 前端告警中心增加「飞书通知设置」弹窗:
   - 开关/Webhook/签名密钥/最低级别/类型白名单
   - 保存配置、发送测试按钮
5. alert_service.create_alert 推送时按数据库配置判断

配置无需改文件/重启,前端界面直接保存即生效。
2026-08-12 13:14:38 +08:00
Your Name 717fa31456 feat(alerts): 告警中心增加飞书机器人告警推送功能
1. 新增 feishu_service.py: 飞书自定义机器人 webhook 推送
   - 支持卡片消息(interactive),按告警级别着色
   - 支持签名校验(HMAC-SHA256)
   - 支持最低级别过滤 + 告警类型白名单
2. alert_service.create_alert 创建新告警时自动推飞书
   - 仅新告警推送,去重告警不刷屏
   - 推送失败不影响告警创建
3. alerts.py 新增两个接口:
   - GET /alerts/notify/feishu/status 查看配置状态
   - POST /alerts/notify/feishu/test 发送测试告警
4. config.py + .env.example 增加 FEISHU_* 配置项
   (WEBHOOK_URL / SECRET / ENABLED / MIN_SEVERITY / ALERT_TYPES)
2026-08-12 13:10:08 +08:00
Your Name 3fd6ba3baf docs(readme): 更新部署文档为目录自适应 + 新增生产服务器更新流程
1. 生产部署建议改为推荐 start.sh 自动化方式(目录自适应、
   systemd 自动生成与开机自启、崩溃自动重启)。
2. 新增「生产服务器更新流程」:git pull 拉到最新代码后
   systemctl restart 三个服务即可,附完整验证命令。
3. 移除旧的手动创建 systemd 单元的过时文档(硬编码 /root/ipam)。
4. 统一把文档里的 /root/ipam 硬编码改为 <项目目录> 中性占位符。
2026-08-12 11:08:55 +08:00
Your Name 18cefa6ded feat(deploy): 目录自适应 + Celery 自动轮询接入 systemd
1. start.sh BASE_DIR 不再硬编码 /root/ipam,改为跟随脚本所在目录
   (BASH_SOURCE 自动检测),无论部署在 /opt /usr/local /root 等
   任意目录都能工作。
2. 运行 start.sh 时自动生成 3 个 systemd 单元并 enable 开机自启:
   - ipam-backend (uvicorn :8008)
   - ipam-celery-worker
   - ipam-celery-beat (每60秒触发SNMP设备自动轮询)
   均含 Restart=always 崩溃自动重启,日志写入 /var/log/ipam/。
3. 前端 vite dev server 保持 nohup 启动(不纳入 systemd)。
4. stop.sh 改为 systemctl stop 停止 systemd 服务,pkill 兜底。
5. .gitignore 忽略 celerybeat-schedule 运行期文件。
2026-08-12 11:05:18 +08:00
Your Name 5369d8d6e5 fix(alerts): 修复检测IP冲突时 db.func 误用导致立即检测500
detect_ip_conflicts 误用 db.func.count/db.func.distinct,而 func 是
SQLAlchemy 模块属性而非 Session 方法,触发 AttributeError 使
/alerts/detect/run 返回500(前端弹'请求失败')。

改为 from sqlalchemy import func 后使用 func.count/func.distinct
2026-08-12 11:01:03 +08:00
Your Name 8775c9836d feat(snmp): 实现网络设备自动轮询定时任务
之前只有 arp/mac/interface_poll_interval 字段但没有实际调度,
SNMP 设备添加后永远不会被自动采集。

新增 poll_snmp_devices Celery 定时任务:
- 遍历所有活跃且配置了 SNMP 凭据的设备
- 按各设备的 poll_interval 判断是否到期,到期才轮询
- 用 asyncio.run 执行异步的 SNMPService.poll_device
- Beat 每 60 秒触发检查,新设备 last_polled_at 为空会立即轮询
2026-08-12 10:56:40 +08:00
Your Name b7551af688 fix(scan): 修复单IP扫描500错误并清除MySQL不兼容SQL
1. enhanced_scan.py: comprehensive_scan_ip 误用 schema 的 IPAddress 做
   db.query 导致 ArgumentError(500)。改用 ORM 模型 IPAddressModel,与
   文件内其他函数一致。
2. enhanced_scan_service.py: update_ip_from_scan_result 使用 PostgreSQL
   的 @> 包含操作符,MySQL 上报 SQL语法错误(1064)。改为纯 Python
   ipaddress 遍历匹配网段,新增 _find_network_for_ip 辅助方法。
2026-08-12 10:47:32 +08:00
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
51 changed files with 41423 additions and 377 deletions
+4
View File
@@ -22,6 +22,10 @@ dist/
*.log
/tmp/
# Celery Beat 调度状态文件(运行期自动生成)
celerybeat-schedule*
celerybeat.pid
# OS
.DS_Store
Thumbs.db
+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')"
```
+113 -78
View File
@@ -53,46 +53,55 @@
### 一键启动所有服务
#### 方式一:使用启动脚本(推荐)
`start.sh` 目录自适应,无论部署在 `/opt``/usr/local``/root` 还是其它任意目录,
直接以脚本所在目录为项目根运行即可(需 root 权限):
```bash
cd /root/ipam
./start.sh
cd /你的部署目录 # 例如 /opt/ipam,跟随你实际的部署路径
bash start.sh
```
运行时会自动:创建 Python venv → 安装后端依赖 → 启动 MySQL/Redis 容器 →
生成 3 个 systemd 服务(backend / celery-worker / celery-beat)并 enable 开机自启 →
启动前端。更多细节见下文「生产环境部署建议」。
#### 方式二:手动启动
> ⚠️ 仅供开发调试用。生产环境推荐使用方式一(start.sh),可自动生成 systemd 服务、开机自启、崩溃自动重启。
#### 安装依赖环境
```bash
cd /root/ipam/backend
# 以下 <项目目录> 均指你实际的部署路径,例如 /opt/ipam
cd <项目目录>/backend
source venv/bin/activate
pip install "pysnmp==4.4.12" "pyasn1<0.5.0" "pysmi<0.4.0"
apt install -y celery
```
##### 1️⃣ 启动后端 API 服务
```bash
cd /root/ipam/backend
cd <项目目录>/backend
source venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8008
```
##### 2️⃣ 启动 Celery Worker(执行扫描任务)
```bash
cd /root/ipam/backend
cd <项目目录>/backend
source venv/bin/activate
celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
```
##### 3️⃣ 启动 Celery Beat(定时任务调度器,自动扫描必须启动)
```bash
cd /root/ipam/backend
cd <项目目录>/backend
source venv/bin/activate
celery -A app.tasks.celery_app beat --loglevel=info
```
##### 4️⃣ 启动前端
```bash
cd /root/ipam/frontend
cd <项目目录>/frontend
npm install
npm run dev -- --host 0.0.0.0 --port 3000
```
@@ -113,7 +122,7 @@ npm run dev -- --host 0.0.0.0 --port 3000
### 如何自定义扫描频率
编辑配置文件:`/root/ipam/backend/app/tasks/celery_app.py`
编辑配置文件:`<项目目录>/backend/app/tasks/celery_app.py`
```python
# 定时任务配置 (第 36-55 行)
@@ -169,7 +178,7 @@ celery_app.conf.beat_schedule = {
#### 查看 Celery 活跃任务
```bash
cd /root/ipam/backend
cd <项目目录>/backend
source venv/bin/activate
celery -A app.tasks.celery_app inspect active
```
@@ -188,83 +197,109 @@ celery -A app.tasks.celery_app inspect stats
## 🛠️ 生产环境部署建议
### 使用 Systemd 管理服务(开机自启
### 推荐方式:使用 start.sh(自动化,目录自适应
#### 1. 创建 IPAM API 服务
```ini
# /etc/systemd/system/ipam-api.service
[Unit]
Description=IPAM API Service
After=network.target mysql.service redis.service
`start.sh` 已内置完整的生产级部署逻辑,**无需手动创建任何 systemd 单元**:
[Service]
Type=simple
User=root
WorkingDirectory=/root/ipam/backend
Environment="PATH=/root/ipam/backend/venv/bin"
ExecStart=/root/ipam/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8008
Restart=always
RestartSec=10
- **目录自适应**:不硬编码路径,自动以脚本所在目录为项目根。无论部署在
`/opt/ipam``/usr/local/ipam``/root/ipam` 还是其它任意目录都能工作。
- **systemd 自动生成**:运行 `start.sh` 时自动生成并 `systemctl enable` 如下
3 个服务,实现**开机自启** + **崩溃自动重启**`Restart=always`):
[Install]
WantedBy=multi-user.target
```
| 服务名 | 内容 | 说明 |
|--------|------|------|
| `ipam-backend` | uvicorn :8008 | 后端 API |
| `ipam-celery-worker` | celery worker | 执行扫描/轮询任务 |
| `ipam-celery-beat` | celery beat | 定时任务调度器(每60秒触发 SNMP 设备自动轮询) |
#### 2. 创建 Celery Worker 服务
```ini
# /etc/systemd/system/ipam-celery-worker.service
[Unit]
Description=IPAM Celery Worker
After=network.target redis.service
日志写入 `/var/log/ipam/*.log`
[Service]
Type=simple
User=root
WorkingDirectory=/root/ipam/backend
Environment="PATH=/root/ipam/backend/venv/bin"
ExecStart=/root/ipam/backend/venv/bin/celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
Restart=always
RestartSec=10
#### 首次部署(全新机器)
[Install]
WantedBy=multi-user.target
```
#### 3. 创建 Celery Beat 服务
```ini
# /etc/systemd/system/ipam-celery-beat.service
[Unit]
Description=IPAM Celery Beat Scheduler
After=network.target redis.service
[Service]
Type=simple
User=root
WorkingDirectory=/root/ipam/backend
Environment="PATH=/root/ipam/backend/venv/bin"
ExecStart=/root/ipam/backend/venv/bin/celery -A app.tasks.celery_app beat --loglevel=info
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
#### 4. 启用并启动服务
```bash
# 重新加载配置
systemctl daemon-reload
# 1. 前置:Python3 / Node.js / Docker 需已安装
# 2. git clone 代码(放到你想要的任何目录)
git clone https://git.cnbugs.com/AI-Agent/ipam.git /opt/ipam
cd /opt/ipam
# 启用开机自启
systemctl enable ipam-api ipam-celery-worker ipam-celery-beat
# 启动服务
systemctl start ipam-api ipam-celery-worker ipam-celery-beat
# 查看服务状态
systemctl status ipam-api ipam-celery-worker ipam-celery-beat
# 3. 一键启动(需 root 权限,自动装依赖 + 起 MySQL/Redis 容器 + 生成 systemd + 启前端)
bash start.sh
```
启动成功后即可访问:
- 管理界面 `http://服务器IP:3000`
- API 文档 `http://服务器IP:8008/docs`
> 💡 首次运行 `start.sh` 会自动创建 Python venv、安装前端依赖(含 vite)、
> 启动 MySQL/Redis Docker 容器、生成 3 个 systemd 服务并 enable 开机自启,
> 全程无需人工干预。若部署目录想换到别处,把项目整个复制过去再跑一次
> `bash start.sh` 即可自动重写 systemd 单元。
#### 服务管理命令
```bash
# 启动 / 停止 / 重启
bash start.sh
bash stop.sh
# 查看状态(开机自启 + 运行中)
systemctl status ipam-backend ipam-celery-worker ipam-celery-beat
# 查看日志
tail -f /var/log/ipam/ipam-backend.log
tail -f /var/log/ipam/ipam-celery-worker.log
tail -f /var/log/ipam/ipam-celery-beat.log
```
---
### 生产服务器更新流程(git 部署时)
生产代码是通过 `git clone` 放上去的,日常更新用 `git pull` 拉取远端最新代码,
再重启服务即可。**推荐更新流程**:
```bash
cd /你的部署目录 # 例如 /opt/ipam,跟随你实际的部署路径
# 1.(可选)备份当前脚本,防止意外
cp start.sh /tmp/start.sh.bak
# 2. 拉取远端最新代码(含全部修复:单IP扫描、SNMP自动轮询、告警检测、部署脚本)
git pull origin master
# 3. 重启服务让新代码生效(需 root)
# - 后端 / Celery Worker / Celery Beat 是 systemd 管理,重启它们即可
systemctl restart ipam-backend ipam-celery-worker ipam-celery-beat
# - 前端 vite dev server 是 nohup 方式,需手动重启:
pkill -f "vite" ; sleep 2
cd frontend && nohup npm run dev -- --host 0.0.0.0 --port 3000 > /tmp/ipam-frontend.log 2>&1 &
# 4. 全量一键启动(更省心:自动检测目录重新生成 systemd + 拉起全部服务)
# bash start.sh
# 5. 验证服务全部正常
systemctl is-active ipam-backend ipam-celery-worker ipam-celery-beat # 应输出 3 个 active
curl -s http://localhost:8008/health # 后端健康
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 # 前端 200
```
**更新要点:**
1. **后端 / Celery 是 systemd 管理**ipam-backend / ipam-celery-worker /
ipam-celery-beat),更新代码后只需 `systemctl restart` 这三个服务,
它们会保持开机自启和崩溃自动重启。
2. **前端是 nohup 方式**vite dev server,开发服务不纳入 systemd),需要
`pkill -f vite` 后重新 `npm run dev` 启动。
3. **依赖变更时**:若 `git pull` 拉取后要求新 Python 包,执行
`cd backend && venv/bin/pip install -r requirements.txt`
前端依赖变化则 `cd frontend && npm install --include=dev`
4. **若你切换了部署目录**(从 /opt 挪到 /usr/local 等),直接在新位置跑一次
`bash start.sh`,它会自动按新目录重新生成 systemd 单元,无需手动改任何配置。
5. **首次部署到新机器**建议直接跑 `bash start.sh`(一键全自动),日常增量更新
用第 2~4 步的重启方式即可,两者效果一致。
---
## 🌐 访问地址
@@ -358,7 +393,7 @@ A: 1. 增加并发数 2. 关闭 DNS 解析(较慢)3. 分网段分时段扫
## 📁 项目结构
```
/root/ipam/
<项目目录>/ # 例如 /opt/ipam、/usr/local/ipam,任意目录均可
├── backend/
│ ├── app/
│ │ ├── api/v1/ # API 路由
+13
View File
@@ -17,3 +17,16 @@ SECRET_KEY=your-super-secret-key-here-change-in-production
PING_TIMEOUT=2
PING_RETRIES=2
SCAN_CONCURRENCY=50
# 飞书机器人告警通知配置
# 1. 在飞书群添加"自定义机器人",复制 Webhook 地址填入 FEISHU_WEBHOOK_URL
# 2. 若机器人开启了"签名校验",把密钥填 FEISHU_SECRET;未开启留空
# 3. FEISHU_ENABLED=true 开启总开关
# 4. FEISHU_MIN_SEVERITY=warning 仅推送 >= 该级别的告警(info/warning/error/critical
# 5. FEISHU_ALERT_TYPES 可选,逗号分隔的告警类型白名单,留空=全部推送
FEISHU_ENABLED=false
FEISHU_WEBHOOK_URL=
FEISHU_SECRET=
FEISHU_MIN_SEVERITY=warning
FEISHU_ALERT_TYPES=
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+91 -2
View File
@@ -5,6 +5,7 @@ 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
from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list, to_business_iso
router = APIRouter(
prefix="/alerts",
@@ -37,7 +38,7 @@ def get_alerts(
total = query.count()
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="获取告警详情")
@@ -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()
if not alert:
raise HTTPException(status_code=404, detail="告警不存在")
return alert
return serialize_dt_fields(alert)
@router.post("/{alert_id}/acknowledge", summary="确认告警")
@@ -180,3 +181,91 @@ def detect_offline_devices(db: Session = Depends(get_db)):
"count": len(offline_devices),
"devices": offline_devices
}
# ========== 飞书告警通知 ==========
@router.get("/notify/feishu/config", summary="获取飞书通知配置")
def get_feishu_config(db: Session = Depends(get_db)):
"""读取飞书通知配置(Web 界面显示用,不返回 secret 原文只返回是否已设置)"""
from app.models.notification import NotificationConfig
row = NotificationConfig.get_singleton(db)
return {
"feishu_enabled": bool(row.feishu_enabled),
"feishu_webhook_url": row.feishu_webhook_url or "",
"feishu_secret_set": bool(row.feishu_secret),
"feishu_min_severity": row.feishu_min_severity or "warning",
"feishu_alert_types": row.feishu_alert_types or "",
}
@router.put("/notify/feishu/config", summary="保存飞书通知配置")
def update_feishu_config(
feishu_enabled: bool,
feishu_webhook_url: str = "",
feishu_secret: str = "",
feishu_min_severity: str = "warning",
feishu_alert_types: str = "",
db: Session = Depends(get_db),
):
"""保存飞书通知配置到数据库并热更新(无需重启后端)"""
from app.models.notification import NotificationConfig
row = NotificationConfig.get_singleton(db)
row.feishu_enabled = bool(feishu_enabled)
row.feishu_webhook_url = (feishu_webhook_url or "").strip()
# 仅当传入新 secret 时才覆盖(避免 UI 回显时清空已有 secret)
if feishu_secret:
row.feishu_secret = feishu_secret.strip()
row.feishu_min_severity = feishu_min_severity or "warning"
row.feishu_alert_types = (feishu_alert_types or "").strip()
db.commit()
# 热更新运行时的飞书服务
from app.services.feishu_service import reload_feishu_service
svc = reload_feishu_service(db)
return {
"message": "飞书通知配置已保存并生效",
"success": True,
"configured": svc.is_configured(),
}
@router.get("/notify/feishu/status", summary="获取飞书通知运行状态")
def get_feishu_status(db: Session = Depends(get_db)):
"""查看飞书通知运行状态"""
from app.services.feishu_service import get_feishu_service
svc = get_feishu_service(db)
return {
"enabled": svc.enabled,
"configured": bool(svc.webhook_url),
"webhook_set": bool(svc.webhook_url),
"secret_set": bool(svc.secret),
"min_severity": svc.min_severity,
"alert_types": svc.alert_types,
"cani_push": svc.is_configured(),
}
@router.post("/notify/feishu/test", summary="发送飞书测试告警")
def test_feishu_notification(db: Session = Depends(get_db)):
"""发送一条测试告警到飞书,验证 webhook 配置是否正确"""
from app.services.feishu_service import get_feishu_service
svc = get_feishu_service(db)
if not svc.is_configured():
raise HTTPException(
status_code=400,
detail="飞书通知未启用或未配置 webhook。请在告警中心「飞书通知设置」中配置并开启"
)
ok = svc.send_alert({
"alert_type": "new_device_detected",
"severity": "warning",
"title": "飞书通知测试",
"message": "这是一条测试告警,用于验证飞书机器人 webhook 配置是否正常。",
"ip_address_str": "172.16.0.1",
"mac_address": "00:11:22:33:44:55",
})
if ok:
return {"message": "飞书测试通知发送成功", "success": True}
raise HTTPException(status_code=500, detail="飞书测试通知发送失败,请检查 webhook 地址和日志")
+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.audit import AuditAction, AuditResource
from app.services.audit_service import AuditService
from app.schemas._tz_util import to_business_iso
router = APIRouter(prefix="/audit", tags=["审计日志"])
@@ -66,7 +67,7 @@ def get_audit_logs(
"request_path": item.request_path,
"success": bool(item.success),
"error_message": item.error_message,
"created_at": item.created_at
"created_at": to_business_iso(item.created_at)
})
return {"total": total, "items": result_items}
+2 -1
View File
@@ -113,7 +113,8 @@ def comprehensive_scan_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()
from app.models.network import IPAddress as IPAddressModel
db_ip = db.query(IPAddressModel).filter(IPAddressModel.ip_address == ip_address).first()
if db_ip:
NetworkService.get_stats(db, db_ip.network_id)
+32 -37
View File
@@ -7,6 +7,7 @@ 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
from app.schemas._tz_util import serialize_dt_fields, serialize_dt_list
router = APIRouter(
prefix="/snmp",
@@ -34,7 +35,7 @@ def get_snmp_credentials(
query = db.query(SNMPCredential)
total = query.count()
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="获取凭据详情")
@@ -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()
if not credential:
raise HTTPException(status_code=404, detail="凭据不存在")
return credential
return serialize_dt_fields(credential)
@router.post("/credentials", summary="创建 SNMP 凭据")
@@ -100,7 +101,7 @@ def create_snmp_credential(
user_agent=u,
detail={"version": credential.version, "name": credential.name},
)
return credential
return serialize_dt_fields(credential)
@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},
},
)
return credential
return serialize_dt_fields(credential)
@router.delete("/credentials/{credential_id}", summary="删除 SNMP 凭据")
@@ -243,29 +244,16 @@ def get_network_devices(
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,
}
item = serialize_dt_fields(d)
# 补充手写的关联字段(datetime 已经由 serialize_dt_fields 序列化)
item["device_type"] = d.device_type.value if hasattr(d.device_type, 'value') else d.device_type
item["credential_name"] = cred_map.get(d.snmp_credential_id)
item["snmp_credential_id"] = d.snmp_credential_id # 显式添加,确保前端能拿到
item["last_polled_at"] = item.get("last_polled_at") # 已是 ISO 字符串
item["last_successful_poll"] = item.get("last_successful_poll")
item["arp_poll_interval"] = d.arp_poll_interval
item["mac_poll_interval"] = d.mac_poll_interval
item["interface_poll_interval"] = d.interface_poll_interval
serialized.append(item)
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()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
return device
return serialize_dt_fields(device)
@router.post("/devices", summary="创建网络设备")
@@ -329,7 +317,7 @@ def create_network_device(
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
return serialize_dt_fields(device)
@router.put("/devices/{device_id}", summary="更新网络设备")
@@ -338,6 +326,7 @@ def update_network_device(
name: Optional[str] = None,
ip_address: Optional[str] = None,
snmp_credential_id: Optional[int] = None,
clear_snmp_credential: bool = False, # 新增:专门的标志
port: Optional[int] = None,
device_type: Optional[str] = None,
description: Optional[str] = None,
@@ -363,7 +352,13 @@ def update_network_device(
device.name = name
if 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
if 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="删除网络设备")
@@ -442,7 +437,7 @@ def delete_network_device(
# ========== 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 连接(不写审计,避免刷屏)"""
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:
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 {
"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="立即轮询设备")
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 地址表、接口信息(不写审计,常规操作)"""
result = SNMPService.poll_device(db, device_id)
result = await SNMPService.poll_device(db, device_id)
if 'error' in result:
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 表")
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 表"""
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:
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 {
"device_id": device_id,
+7
View File
@@ -28,6 +28,13 @@ class Settings(BaseSettings):
PING_RETRIES: int = 2
SCAN_CONCURRENCY: int = 50
# 飞书机器人告警通知配置
FEISHU_ENABLED: bool = False # 总开关
FEISHU_WEBHOOK_URL: str = "" # 飞书自定义机器人 webhook 地址
FEISHU_SECRET: str = "" # 可选:机器人签名校验密钥(开启签名校验时必填)
FEISHU_MIN_SEVERITY: str = "warning" # 最低告警级别:info/warning/error/critical,仅推送 >= 该级别
FEISHU_ALERT_TYPES: str = "" # 可选:逗号分隔的告警类型白名单,空=全部推送
class Config:
env_file = ".env"
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,6 +12,7 @@ from app.models.snmp import SNMPCredential, NetworkDevice, ARPEntry, MACAddressT
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
from app.models.notification import NotificationConfig
@asynccontextmanager
+31
View File
@@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
from sqlalchemy.sql import func
from app.core.database import Base
class NotificationConfig(Base):
"""通知渠道配置(当前支持飞书机器人)。
使用单行记录(id=1)存储,便于 Web 界面读写和热更新。
"""
__tablename__ = "notification_config"
id = Column(Integer, primary_key=True)
# 飞书
feishu_enabled = Column(Boolean, default=False) # 总开关
feishu_webhook_url = Column(String(500), default="") # 机器人 webhook 地址
feishu_secret = Column(String(255), default="") # 签名校验密钥(可选)
feishu_min_severity = Column(String(20), default="warning") # 最低告警级别
feishu_alert_types = Column(Text, default="") # 逗号分隔的类型白名单,空=全部
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@classmethod
def get_singleton(cls, db):
"""获取或创建单行配置"""
row = db.query(cls).filter(cls.id == 1).first()
if row is None:
row = cls(id=1)
db.add(row)
db.commit()
db.refresh(row)
return row
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 datetime import datetime
import ipaddress
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 ==========
@@ -55,6 +65,11 @@ class Network(NetworkBase):
created_at: 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:
from_attributes = True
@@ -120,6 +135,10 @@ class IPAddress(IPAddressBase):
created_at: 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:
from_attributes = True
@@ -155,6 +174,13 @@ class ScanTask(BaseModel):
error_message: Optional[str]
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:
from_attributes = True
@@ -170,4 +196,4 @@ class ScanResult(BaseModel):
status: str
mac_address: Optional[str] = None
hostname: Optional[str] = None
response_time: Optional[float] = None
response_time: Optional[float] = None
+24 -1
View File
@@ -1,5 +1,6 @@
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, timedelta
import logging
import json
@@ -11,6 +12,12 @@ from app.models.alert import (
from app.models.network import Network, IPAddress
from app.models.snmp import NetworkDevice, ARPEntry
# 飞书告警通知(延迟导入避免循环依赖)
def _get_feishu_service(db=None):
from app.services.feishu_service import get_feishu_service
return get_feishu_service(db)
logger = logging.getLogger(__name__)
@@ -69,6 +76,22 @@ class AlertService:
db.refresh(alert)
logger.info(f"创建告警: {alert_type} - {title}")
# 飞书告警通知(仅新创建的告警推送;existing 去重返回的不会走到这里,避免刷屏)
try:
_get_feishu_service(db).send_alert({
"alert_type": alert_type.value if hasattr(alert_type, "value") else str(alert_type),
"severity": severity.value if hasattr(severity, "value") else str(severity),
"title": title,
"message": message,
"ip_address_str": ip_address_str,
"mac_address": mac_address,
"conflicting_mac": conflicting_mac,
"usage_percent": usage_percent,
})
except Exception as e:
logger.error(f"飞书告警通知失败(不影响告警创建): {e}")
return alert
@staticmethod
@@ -80,7 +103,7 @@ class AlertService:
subquery = db.query(
ARPEntry.ip_address
).group_by(ARPEntry.ip_address).having(
db.func.count(db.func.distinct(ARPEntry.mac_address)) > 1
func.count(func.distinct(ARPEntry.mac_address)) > 1
).subquery()
conflict_entries = db.query(ARPEntry).filter(
+75 -15
View File
@@ -7,6 +7,7 @@ import socket
import ipaddress
import re
import logging
import os
try:
from mac_vendor_lookup import MacLookup, VendorNotFoundError
@@ -20,6 +21,40 @@ from app.core.config import settings
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:
"""增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别、NetBIOS主机名发现"""
@@ -45,13 +80,23 @@ class EnhancedScanService:
if len(mac) < 8:
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:
lookup = EnhancedScanService._get_mac_lookup()
if lookup is not None:
try:
# 提取 OUI(前 3 字节)
oui = mac[:8] # "AA:BB:CC"
vendor = lookup.lookup(oui)
if vendor:
return vendor
@@ -60,7 +105,7 @@ class EnhancedScanService:
except Exception:
pass
# 回退:硬编码常用厂商映射
# 3️⃣ 回退:硬编码常用厂商映射
return EnhancedScanService._legacy_oui_lookup(mac)
# 硬编码回退映射(mac_vendor_lookup 不可用时使用)
@@ -384,17 +429,7 @@ class EnhancedScanService:
# 如果IP不存在,尝试自动创建
if not db_ip:
network = db.query(Network).filter(
Network.cidr.op('@>')((ip_address + '/32').encode('utf-8'))
).first()
if not network:
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
network = EnhancedScanService._find_network_for_ip(db, ip_address)
if not network:
logger.warning(f"无法为扫描到的IP {ip_address} 找到所属网段,跳过创建")
return
@@ -436,6 +471,31 @@ class EnhancedScanService:
return db_ip
@staticmethod
def _find_network_for_ip(db: Session, ip_address: str) -> Optional[Network]:
"""
查找 IP 所属的网段(兼容 MySQL,不使用 PostgreSQL 的 @> 操作符)。
优先按网段越大越精确匹配,无法精确匹配时返回包含该 IP 的最小/最合适网段。
"""
try:
target = ipaddress.ip_address(ip_address)
except ValueError:
return None
best = None
best_prefix = -1
for net in db.query(Network).all():
try:
cidr = str(net.cidr)
net_obj = ipaddress.ip_network(cidr, strict=False)
if target in net_obj:
# 取前缀最长的(网段最小、最精确)
if net_obj.prefixlen > best_prefix:
best = net
best_prefix = net_obj.prefixlen
except Exception:
continue
return best
@staticmethod
def bulk_update_ips_from_scan(db: Session, scan_results: List[Dict[str, Any]]):
"""批量更新IP信息"""
+284
View File
@@ -0,0 +1,284 @@
"""
飞书机器人告警通知服务
========================
通过飞书自定义机器人 Webhook 推送告警通知,支持:
- 卡片消息(interactive
- 可选签名校验(加签模式)
- 按告警级别 / 类型过滤
- 同步发送(带超时),失败自动记录日志
"""
from typing import Optional, Dict, Any, List
from datetime import datetime
import base64
import hashlib
import hmac
import json
import logging
import time
import httpx
logger = logging.getLogger(__name__)
# 告警级别 → 颜色(飞书卡片 title 颜色)
SEVERITY_COLORS = {
"critical": "red",
"error": "red",
"warning": "orange",
"info": "blue",
}
# 告警级别 → 中文
SEVERITY_TEXTS = {
"critical": "严重",
"error": "错误",
"warning": "警告",
"info": "信息",
}
# 告警类型 → 中文
ALERT_TYPE_TEXTS = {
"ip_conflict": "IP 地址冲突",
"unauthorized_access": "未授权设备接入",
"subnet_full": "网段容量不足",
"scan_failed": "扫描失败",
"device_offline": "设备离线",
"new_device_detected": "新设备发现",
"mac_changed": "MAC 地址变更",
}
# 告警级别优先级(用于 >= 最低级别过滤)
_SEVERITY_ORDER = {"info": 0, "warning": 1, "error": 2, "critical": 3}
def _apply_sign(timestamp: str, secret: str) -> str:
"""飞书机器人签名校验:HMAC-SHA256(secret, timestamp + "\n" + secret),结果 base64"""
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
return base64.b64encode(hmac_code).decode("utf-8")
def build_card(alert_data: Dict[str, Any]) -> str:
"""把告警数据构造成飞书卡片消息体(interactive)。"""
severity = str(alert_data.get("severity") or "warning").lower()
alert_type = str(alert_data.get("alert_type") or "unknown").lower()
color = SEVERITY_COLORS.get(severity, "blue")
severity_text = SEVERITY_TEXTS.get(severity, severity)
alert_type_text = ALERT_TYPE_TEXTS.get(alert_type, alert_type)
title = alert_data.get("title") or "IPAM 告警"
message = alert_data.get("message") or ""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 组装字段(element),过滤空字段
fields = []
def add_field(tag_text, content_text):
if content_text:
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**{tag_text}** {content_text}"}})
add_field("告警级别", f"<font color='{color}'><b>{severity_text}</b></font>")
add_field("告警类型", alert_type_text)
add_field("详细信息", message)
add_field("相关 IP", alert_data.get("ip_address_str"))
add_field("相关 MAC", alert_data.get("mac_address"))
add_field("冲突 MAC", alert_data.get("conflicting_mac"))
if alert_data.get("usage_percent") is not None:
add_field("网段使用率", f"{alert_data['usage_percent']}%")
add_field("告警时间", now)
card = {
"msg_type": "interactive",
"card": {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": f"⚠️ IPAM 告警 - {title}"},
"template": color,
},
"elements": [
{"tag": "div", "fields": fields},
{
"tag": "note",
"elements": [
{"tag": "plain_text", "content": f"本消息由 IPAM 地址管理系统自动发送 · {now}"}
],
},
],
},
}
return json.dumps(card, ensure_ascii=False)
class FeishuService:
"""飞书机器人告警推送服务"""
def __init__(
self,
webhook_url: str = "",
secret: str = "",
enabled: bool = True,
min_severity: str = "warning",
alert_types: Optional[List[str]] = None,
):
self.webhook_url = webhook_url
self.secret = secret
self.enabled = enabled
self.min_severity = min_severity
self.alert_types = alert_types or []
self._client = httpx.Client(timeout=8.0)
def is_configured(self) -> bool:
"""是否已配置 webhook 地址且开关打开"""
return bool(self.enabled and self.webhook_url)
def should_push(self, severity: str, alert_type: str = "") -> bool:
"""按最低级别 + 类型白名单判断是否应推送"""
if not self.is_configured():
return False
# 级别过滤
sev = (severity or "warning").lower()
if _SEVERITY_ORDER.get(sev, 1) < _SEVERITY_ORDER.get(self.min_severity, 1):
return False
# 类型白名单(空 = 全部推送)
if self.alert_types and alert_type and alert_type not in self.alert_types:
return False
return True
def _build_headers(self) -> Dict[str, str]:
"""构造签名头(若配置了 secret"""
headers = {"Content-Type": "application/json; charset=utf-8"}
if self.secret:
timestamp = str(round(time.time()))
sign = _apply_sign(timestamp, self.secret)
headers["X-Lark-Sign"] = sign
headers["X-Lark-Timestamp"] = timestamp
return headers
def send_card(self, card_payload: str) -> bool:
"""发送飞书消息(card_payload 为 build_card 构造的 JSON 字符串)"""
if not self.is_configured():
logger.warning("飞书通知未启用或未配置 webhook,已跳过")
return False
try:
headers = self._build_headers()
resp = self._client.post(self.webhook_url, content=card_payload.encode("utf-8"), headers=headers)
try:
data = resp.json()
except Exception:
data = {"msg": resp.text}
# 飞书返回 code=0 表示成功
if resp.status_code == 200 and data.get("code") == 0:
logger.info(f"飞书通知发送成功: {data.get('msg', 'ok')}")
return True
logger.error(f"飞书通知发送失败: HTTP {resp.status_code} body={data}")
return False
except Exception as e:
logger.error(f"飞书通知发送异常: {str(e)}", exc_info=True)
return False
def send_alert(self, alert_data: Dict[str, Any]) -> bool:
"""发送一条告警通知(自动判断是否应推送)"""
severity = str(alert_data.get("severity") or "warning").lower()
alert_type = str(alert_data.get("alert_type") or "unknown").lower()
if not self.should_push(severity, alert_type):
logger.debug(f"告警 {severity}/{alert_type} 低于推送门槛或不在白名单,跳过")
return False
card = build_card(alert_data)
return self.send_card(card)
def close(self):
if self._client:
self._client.close()
# 全局单例
_service: Optional[FeishuService] = None
def _build_from_values(webhook_url="", secret="", enabled=False,
min_severity="warning", alert_types_str="") -> FeishuService:
"""用一组值构造 FeishuServiceapi 层用)"""
if _service is not None:
# 热更新:复用同一实例,更新内部配置
_service.webhook_url = webhook_url
_service.secret = secret
_service.enabled = bool(enabled)
_service.min_severity = min_severity or "warning"
_service.alert_types = [t.strip() for t in (alert_types_str or "").split(",") if t.strip()]
return _service
return FeishuService(
webhook_url=webhook_url,
secret=secret,
enabled=bool(enabled),
min_severity=min_severity or "warning",
alert_types=[t.strip() for t in (alert_types_str or "").split(",") if t.strip()],
)
def get_feishu_service(db=None) -> FeishuService:
"""获取(缓存的)飞书服务实例。
优先从数据库 notification_config 读取配置(Web 界面可热更新);
若无数据库配置则回退到 .env / settings 默认值。
"""
global _service
# 优先从数据库加载(Web 配置优先)
try:
if db is not None:
from app.models.notification import NotificationConfig
row = db.query(NotificationConfig).filter(NotificationConfig.id == 1).first()
if row is not None:
return _build_from_values(
webhook_url=row.feishu_webhook_url or "",
secret=row.feishu_secret or "",
enabled=row.feishu_enabled or False,
min_severity=row.feishu_min_severity or "warning",
alert_types_str=row.feishu_alert_types or "",
)
except Exception as e:
logger.warning(f"从数据库读取飞书配置失败,回退 .env: {e}")
# 回退到 settings.env
if _service is None:
try:
from app.core.config import settings
_service = _build_from_values(
webhook_url=settings.FEISHU_WEBHOOK_URL,
secret=settings.FEISHU_SECRET,
enabled=settings.FEISHU_ENABLED,
min_severity=settings.FEISHU_MIN_SEVERITY or "warning",
alert_types_str=settings.FEISHU_ALERT_TYPES or "",
)
except Exception as e:
logger.error(f"初始化飞书服务失败: {e}")
_service = FeishuService()
return _service
def reload_feishu_service(db) -> FeishuService:
"""强制从数据库重新加载飞书配置(Web 界面保存配置后调用)"""
global _service
from app.models.notification import NotificationConfig
row = db.query(NotificationConfig).filter(NotificationConfig.id == 1).first()
if row is None:
row = NotificationConfig.get_singleton(db)
svc = _build_from_values(
webhook_url=row.feishu_webhook_url or "",
secret=row.feishu_secret or "",
enabled=row.feishu_enabled or False,
min_severity=row.feishu_min_severity or "warning",
alert_types_str=row.feishu_alert_types or "",
)
global _service
_service = svc
return svc
def send_alert_notification(alert_data: Dict[str, Any], db=None) -> bool:
"""便捷函数:发送一条告警通知"""
return get_feishu_service(db).send_alert(alert_data)
+2 -2
View File
@@ -14,9 +14,9 @@ class NetworkService:
@staticmethod
def calculate_total_ips(cidr: str) -> int:
"""计算网段的总IP数量"""
"""计算网段中实际分配的IP数量(排除网络地址和广播地址)"""
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
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
import ipaddress
import logging
import asyncio
from pysnmp.hlapi import (
from pysnmp.hlapi.asyncio import (
SnmpEngine, CommunityData, UsmUserData,
UdpTransportTarget, ContextData,
ObjectType, ObjectIdentity,
@@ -117,7 +118,7 @@ class SNMPService:
return None
@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 连接
返回: (成功, 设备信息字典)
@@ -131,11 +132,11 @@ class SNMPService:
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)))
errorIndication, errorStatus, errorIndex, varBinds = await 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:
@@ -162,7 +163,7 @@ class SNMPService:
return False, {'error': str(e)}
@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 映射)
"""
@@ -178,61 +179,60 @@ class SNMPService:
)
# 使用 bulkCmd 获取 ARP 表
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
errorIndication, errorStatus, errorIndex, varBindTable = await 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 errorIndication:
logger.error(f"SNMP 错误: {errorIndication}")
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:
logger.error(f"SNMP 错误: {errorStatus}")
break
# 提取 IP 地址 (OID 后缀: ifIndex.IP)
parts = oid.split('.')
if len(parts) >= 4:
# 从 OID 中提取 IP 地址
ip_parts = parts[-4:]
ip_address = '.'.join(ip_parts)
# 解析结果
arp_data = {}
for varBind in varBinds:
oid = str(varBind[0])
value = varBind[1]
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
# 提取 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
# 转换为列表
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)
# 转换为列表
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:
@@ -258,16 +258,18 @@ class SNMPService:
db.commit()
# 更新 IP 资产台账中的 MAC 地址
# 更新 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'])
if ip_addr:
if not ip_addr.mac_address:
ip_addr.mac_address = 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()
device.last_successful_poll = now
@@ -281,7 +283,7 @@ class SNMPService:
return arp_entries
@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 -> 端口 映射)
"""
@@ -297,39 +299,39 @@ class SNMPService:
)
# 获取 MAC 地址表
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
errorIndication, errorStatus, errorIndex, varBindTable = await 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 errorIndication:
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:
break
# 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)
for varBind in varBinds:
oid = str(varBind[0])
value = varBind[1]
if SNMPService.OID_DOT1D_TP_FDB_PORT in oid:
port = int(value) if isinstance(value, Integer32) else None
# 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)
entry = {
'mac_address': mac_address,
'port_number': port,
'device_id': device.id,
'vlan_id': vlan_id
}
mac_entries.append(entry)
# 保存到数据库
now = datetime.utcnow()
@@ -361,7 +363,7 @@ class SNMPService:
return mac_entries
@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 = {}
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
errorIndication, errorStatus, errorIndex, varBindTable = await bulkCmd(
SnmpEngine(), auth_data, transport, ContextData(),
0, 100,
ObjectType(ObjectIdentity(SNMPService.OID_IF_DESCR)),
@@ -390,35 +392,34 @@ class SNMPService:
ObjectType(ObjectIdentity(SNMPService.OID_IF_ADMIN_STATUS)),
ObjectType(ObjectIdentity(SNMPService.OID_IF_OPER_STATUS)),
lexicographicMode=False
):
if errorIndication or errorStatus:
break
)
if not errorIndication and not errorStatus:
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:
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 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')
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()
@@ -485,7 +486,7 @@ class SNMPService:
return device
@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()
if not device:
@@ -505,17 +506,17 @@ class SNMPService:
}
# 获取 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_count'] = len(arp_entries)
# 获取 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_count'] = len(mac_entries)
# 获取接口信息
interfaces = SNMPService.get_interfaces(device, credential, db)
interfaces = await SNMPService.get_interfaces(device, credential, db)
result['interfaces'] = interfaces
result['interface_count'] = len(interfaces)
+6
View File
@@ -32,6 +32,7 @@ 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']
poll_snmp_devices = _tasks['poll_snmp_devices']
# 定时任务配置
celery_app.conf.beat_schedule = {
@@ -52,4 +53,9 @@ celery_app.conf.beat_schedule = {
'task': 'app.tasks.scan_tasks.update_all_statistics',
'schedule': 900.0, # 15分钟
},
# 每60秒检查一次 SNMP 设备轮询(各设备按自己的 poll_interval 判断是否到期)
'poll-snmp-devices-every-60s': {
'task': 'app.tasks.scan_tasks.poll_snmp_devices',
'schedule': 60.0,
},
}
+65 -1
View File
@@ -212,11 +212,75 @@ def register_tasks(celery_app):
finally:
db.close()
@celery_app.task(bind=True)
def poll_snmp_devices(self):
"""
定时任务自动轮询所有活跃且配置了 SNMP 凭据的网络设备
每个设备按自己的 arp/mac/interface_poll_interval判断是否到达
轮询间隔到期才执行 SNMPService.poll_device采集 ARP MAC 地址
接口信息 Celery Beat 60 秒触发一次检查
"""
import asyncio
from datetime import datetime
from app.models.snmp import NetworkDevice
from app.services.snmp_service import SNMPService
db = SessionLocal()
try:
devices = db.query(NetworkDevice).filter(
NetworkDevice.is_active == True,
NetworkDevice.snmp_credential_id.isnot(None),
).all()
now = datetime.utcnow()
def _as_naive(dt):
"""把 aware/naive datetime 统一转 naive(本地比较用)"""
if dt is None:
return None
if dt.tzinfo is not None:
return dt.replace(tzinfo=None)
return dt
polled, skipped = 0, 0
for dev in devices:
# 取三种采集间隔的最小值作为该设备的轮询周期
intervals = [
dev.arp_poll_interval,
dev.mac_poll_interval,
dev.interface_poll_interval,
]
interval = min([i for i in intervals if i], default=300)
last = _as_naive(dev.last_polled_at)
if last is not None:
elapsed = (now - last).total_seconds()
if elapsed < interval:
skipped += 1
continue
try:
# poll_device 是 async 函数,用 asyncio.run 在同步任务中执行
asyncio.run(SNMPService.poll_device(db, dev.id))
polled += 1
logger.info(f"SNMP 自动轮询完成: {dev.name} ({dev.ip_address})")
except Exception as e:
logger.error(f"SNMP 自动轮询失败 {dev.name} ({dev.ip_address}): {e}", exc_info=True)
logger.info(f"SNMP 自动轮询检查完成: 设备总数 {len(devices)}, 本轮轮询 {polled}, 未到期跳过 {skipped}")
return {'status': 'completed', 'total': len(devices), 'polled': polled, 'skipped': skipped}
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
'quick_status_update': quick_status_update,
'poll_snmp_devices': poll_snmp_devices,
}
+12 -1
View File
@@ -1,3 +1,4 @@
# Python 3.10+ 兼容版本
fastapi==0.110.0
uvicorn[standard]==0.27.1
sqlalchemy==2.0.28
@@ -9,8 +10,18 @@ celery==5.3.6
redis==5.0.3
python-multipart==0.0.9
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
python-dotenv==1.0.1
passlib[bcrypt]==1.7.4
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",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"dev": "node node_modules/vite/bin/vite.js",
"dev:clean": "rm -rf node_modules/.vite && node node_modules/vite/bin/vite.js --force",
"build": "node node_modules/vite/bin/vite.js build",
"preview": "node node_modules/vite/bin/vite.js preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
+13
View File
@@ -131,6 +131,19 @@ export const alertApi = {
},
removeFromWhitelist(id) {
return api.delete(`/alerts/whitelist/macs/${id}`)
},
// 飞书通知配置
getFeishuConfig() {
return api.get('/alerts/notify/feishu/config')
},
saveFeishuConfig(data) {
return api.put('/alerts/notify/feishu/config', {}, { params: data })
},
getFeishuStatus() {
return api.get('/alerts/notify/feishu/status')
},
testFeishu() {
return api.post('/alerts/notify/feishu/test')
}
}
+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}`
}
+109 -5
View File
@@ -13,6 +13,10 @@
<el-icon><Check /></el-icon>
全部确认
</el-button>
<el-button @click="openFeishuDialog">
<el-icon><ChatDotRound /></el-icon>
飞书通知设置
</el-button>
</el-button-group>
</div>
</template>
@@ -141,7 +145,7 @@
</el-table-column>
<el-table-column prop="created_at" label="时间" width="180">
<template #default="scope">
{{ formatDate(scope.row.created_at) }}
{{ formatDateTime(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
@@ -194,7 +198,7 @@
<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) }}
{{ formatDateTime(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
@@ -231,6 +235,47 @@
<el-button type="primary" @click="addToWhitelist" :loading="submitting">确定</el-button>
</template>
</el-dialog>
<!-- 飞书通知设置弹窗 -->
<el-dialog v-model="feishuDialogVisible" title="飞书通知设置" width="560px">
<el-alert type="info" :closable="false" style="margin-bottom: 16px"
title="在飞书群添加「自定义机器人」,将 Webhook 地址填入下方即可。开启后,检测到新告警会自动推送到飞书群。"
show-icon />
<el-form label-width="110px" label-position="left">
<el-form-item label="启用通知">
<el-switch v-model="feishuForm.feishu_enabled" active-text="开启" inactive-text="关闭" />
</el-form-item>
<el-form-item label="Webhook 地址">
<el-input v-model="feishuForm.feishu_webhook_url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxxx" />
</el-form-item>
<el-form-item label="签名密钥">
<el-input v-model="feishuForm.feishu_secret" show-password
:placeholder="feishuForm.feishu_secret_set ? '已设置(留空则不修改)' : '机器人开启签名校验时填写'" />
<div class="form-tip">机器人开启签名校验时必填未开启留空即可</div>
</el-form-item>
<el-form-item label="最低级别">
<el-select v-model="feishuForm.feishu_min_severity" style="width: 200px">
<el-option label="全部 (info起)" value="info" />
<el-option label="警告及以上" value="warning" />
<el-option label="错误及以上" value="error" />
<el-option label="仅严重" value="critical" />
</el-select>
<div class="form-tip">仅推送大于等于所选级别的告警</div>
</el-form-item>
<el-form-item label="类型白名单">
<el-input v-model="feishuForm.feishu_alert_types" placeholder="留空=全部推送;如 ip_conflict,device_offline" />
<div class="form-tip">逗号分隔告警类型留空则推送全部类型</div>
</el-form-item>
</el-form>
<div style="color: #909399; font-size: 12px; margin-top: 8px;">
配置保存后立即生效无需重启服务
</div>
<template #footer>
<el-button @click="feishuDialogVisible = false">关闭</el-button>
<el-button @click="testFeishu" :loading="testing">发送测试</el-button>
<el-button type="primary" @click="saveFeishuConfig" :loading="savingFeishu">保存配置</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -238,6 +283,7 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { alertApi } from '@/api'
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
const loading = ref(false)
const detecting = ref(false)
@@ -460,9 +506,67 @@ const getAlertTypeText = (type) => {
return texts[type] || type
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
// formatDate @/utils/datetime
// ---- ----
const feishuDialogVisible = ref(false)
const savingFeishu = ref(false)
const testing = ref(false)
const feishuForm = reactive({
feishu_enabled: false,
feishu_webhook_url: '',
feishu_secret: '',
feishu_secret_set: false,
feishu_min_severity: 'warning',
feishu_alert_types: ''
})
const openFeishuDialog = async () => {
feishuDialogVisible.value = true
try {
const cfg = await alertApi.getFeishuConfig()
feishuForm.feishu_enabled = !!cfg.feishu_enabled
feishuForm.feishu_webhook_url = cfg.feishu_webhook_url || ''
feishuForm.feishu_secret = ''
feishuForm.feishu_secret_set = !!cfg.feishu_secret_set
feishuForm.feishu_min_severity = cfg.feishu_min_severity || 'warning'
feishuForm.feishu_alert_types = cfg.feishu_alert_types || ''
} catch (error) {
ElMessage.error('加载飞书配置失败')
}
}
const saveFeishuConfig = async () => {
savingFeishu.value = true
try {
const params = {
feishu_enabled: feishuForm.feishu_enabled,
feishu_webhook_url: feishuForm.feishu_webhook_url,
feishu_secret: feishuForm.feishu_secret,
feishu_min_severity: feishuForm.feishu_min_severity,
feishu_alert_types: feishuForm.feishu_alert_types
}
const res = await alertApi.saveFeishuConfig(params)
ElMessage.success(res.message || '飞书配置已保存')
feishuForm.feishu_secret = ''
feishuForm.feishu_secret_set = true
} catch (error) {
ElMessage.error('保存飞书配置失败')
} finally {
savingFeishu.value = false
}
}
const testFeishu = async () => {
testing.value = true
try {
const res = await alertApi.testFeishu()
ElMessage.success(res.message || '测试通知已发送')
} catch (error) {
ElMessage.error(error?.response?.data?.detail || '发送测试失败,请检查配置')
} finally {
testing.value = false
}
}
onMounted(() => {
+3 -5
View File
@@ -107,7 +107,7 @@
</el-table-column>
<el-table-column prop="last_seen" label="最后发现" width="170">
<template #default="scope">
{{ scope.row.last_seen ? formatDate(scope.row.last_seen) : '-' }}
{{ scope.row.last_seen ? formatDateTime(scope.row.last_seen) : '-' }}
</template>
</el-table-column>
<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 { ElMessage } from 'element-plus'
import { ipApi, networkApi, scanApi } from '@/api'
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
const route = useRoute()
@@ -350,10 +351,7 @@ const getStatusText = (status) => {
return texts[status] || status
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
// formatDate @/utils/datetime
onMounted(() => {
loadNetworks()
+3 -5
View File
@@ -58,7 +58,7 @@
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="scope">
{{ formatDate(scope.row.created_at) }}
{{ formatDateTime(scope.row.created_at) }}
</template>
</el-table-column>
<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 { ElMessage, ElMessageBox } from 'element-plus'
import { networkApi, scanApi } from '@/api'
import { formatDateTime, formatDate } from '@/utils/datetime'
const router = useRouter()
@@ -304,10 +305,7 @@ const getGroupTagType = (group) => {
return types[group] || 'info'
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
// formatDate @/utils/datetime
onMounted(() => {
loadNetworks()
+12 -8
View File
@@ -39,7 +39,7 @@
</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) : '-' }}
{{ scope.row.last_polled_at ? formatDateTime(scope.row.last_polled_at) : '-' }}
</template>
</el-table-column>
<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="created_at" label="创建时间" width="180">
<template #default="scope">
{{ formatDate(scope.row.created_at) }}
{{ formatDateTime(scope.row.created_at) }}
</template>
</el-table-column>
<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="last_seen" label="最后发现" width="180">
<template #default="scope">
{{ formatDate(scope.row.last_seen) }}
{{ formatDateTime(scope.row.last_seen) }}
</template>
</el-table-column>
</el-table>
@@ -275,6 +275,7 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { snmpApi } from '@/api'
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
const activeTab = ref('devices')
const loading = ref(false)
@@ -397,7 +398,13 @@ const saveDevice = async () => {
submitting.value = true
try {
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('更新成功')
} else {
await snmpApi.createDevice(deviceForm)
@@ -530,10 +537,7 @@ const deleteCredential = async (id) => {
}
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
// formatDate @/utils/datetime
onMounted(() => {
loading.value = true
+3 -10
View File
@@ -58,7 +58,7 @@
</el-table-column>
<el-table-column label="最后登录" width="170">
<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>
</el-table-column>
<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 { Plus, Search, Refresh } from '@element-plus/icons-vue'
import { userApi } from '@/api/auth'
import { formatDateTime, formatDate, formatRelative } from '@/utils/datetime'
import { authStore } from '@/utils/auth'
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 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 }
}
// formatDate @/utils/datetime
async function loadUsers() {
loading.value = true
+196 -66
View File
@@ -1,5 +1,13 @@
#!/bin/bash
# IPAM 管理系统一键启动脚本
#
# 特点:
# - 目录自适应:不硬编码 /root/ipam,自动跟随本脚本所在目录。
# 无论部署在 /opt、/usr/local、/root 还是其它任意目录都能工作。
# - systemd 管理:后端、Celery Worker、Celery Beat 都生成 systemd 单元
# ipam-backend / ipam-celery-worker / ipam-celery-beat),
# 运行本脚本时自动生成并 enable,保证开机自启、崩溃自动重启。
# - 前端 vite dev server 仍用 nohup 启动(开发服务,不纳入 systemd)。
echo "=========================================="
echo " IPAM 管理系统启动脚本"
@@ -12,8 +20,10 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 项目根目录
BASE_DIR="/root/ipam"
# ============ 目录自适应(核心改动)============
# 无论脚本放哪里,都以其所在目录为项目根目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_DIR="$SCRIPT_DIR"
BACKEND_DIR="$BASE_DIR/backend"
FRONTEND_DIR="$BASE_DIR/frontend"
@@ -21,20 +31,31 @@ FRONTEND_DIR="$BASE_DIR/frontend"
API_PORT=8008
FRONTEND_PORT=3000
# systemd 服务名
SYSTEMD_BACKEND="ipam-backend"
SYSTEMD_CELERY_WORKER="ipam-celery-worker"
SYSTEMD_CELERY_BEAT="ipam-celery-beat"
# 日志目录
LOG_DIR="/var/log/ipam"
mkdir -p "$LOG_DIR" 2>/dev/null
echo -e "${GREEN}📁 项目目录: $BASE_DIR${NC}"
# 检查 Docker 容器状态
check_docker_containers() {
echo "🔍 检查 Docker 容器..."
# 检查 MySQL
if [ "$(docker inspect -f '{{.State.Running}}' ipam-mysql 2>/dev/null)" != "true" ]; then
echo -e "${YELLOW}MySQL 容器未运行,正在启动...${NC}"
docker start ipam-mysql 2>/dev/null || {
echo -e "${YELLOW}创建 MySQL 容器...${NC}"
docker run -d --name ipam-mysql -p 3308:3306 \
-e MYSQL_ROOT_PASSWORD=*** \
-e MYSQL_ROOT_PASSWORD=ipam2024 \
-e MYSQL_DATABASE=ipam \
-e MYSQL_USER=ipam \
-e MYSQL_PASSWORD=*** \
-e MYSQL_PASSWORD=ipam2024 \
mysql:8.0 --default-authentication-plugin=mysql_native_password
}
fi
@@ -63,105 +84,208 @@ kill_port() {
fi
}
# 启动后端 API
# ============ systemd 单元动态生成 ============
# 生成 3 个服务单元(backend / celery-worker / celery-beat
# 路径全部跟随 BASE_DIR,保证部署目录变更后重新运行本脚本即可重写
setup_systemd_services() {
echo "⚙️ 生成 systemd 服务单元..."
# --- 后端 ---
cat > /etc/systemd/system/$SYSTEMD_BACKEND.service <<EOF
[Unit]
Description=IPAM Backend API (uvicorn)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=$BACKEND_DIR
Environment=PYTHONPATH=$BACKEND_DIR
ExecStart=$BACKEND_DIR/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port $API_PORT
Restart=always
RestartSec=3
StandardOutput=append:$LOG_DIR/ipam-backend.log
StandardError=append:$LOG_DIR/ipam-backend.log
[Install]
WantedBy=multi-user.target
EOF
# --- Celery Worker ---
cat > /etc/systemd/system/$SYSTEMD_CELERY_WORKER.service <<EOF
[Unit]
Description=IPAM Celery Worker
After=$SYSTEMD_BACKEND.service network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=$BACKEND_DIR
Environment=PYTHONPATH=$BACKEND_DIR
ExecStart=$BACKEND_DIR/venv/bin/celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
Restart=always
RestartSec=5
StandardOutput=append:$LOG_DIR/ipam-celery-worker.log
StandardError=append:$LOG_DIR/ipam-celery-worker.log
[Install]
WantedBy=multi-user.target
EOF
# --- Celery Beat ---
cat > /etc/systemd/system/$SYSTEMD_CELERY_BEAT.service <<EOF
[Unit]
Description=IPAM Celery Beat Scheduler
After=$SYSTEMD_CELERY_WORKER.service network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=$BACKEND_DIR
Environment=PYTHONPATH=$BACKEND_DIR
ExecStart=$BACKEND_DIR/venv/bin/celery -A app.tasks.celery_app beat --loglevel=info
Restart=always
RestartSec=5
StandardOutput=append:$LOG_DIR/ipam-celery-beat.log
StandardError=append:$LOG_DIR/ipam-celery-beat.log
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
# enable 开机自启
systemctl enable $SYSTEMD_BACKEND >/dev/null 2>&1
systemctl enable $SYSTEMD_CELERY_WORKER >/dev/null 2>&1
systemctl enable $SYSTEMD_CELERY_BEAT >/dev/null 2>&1
echo -e "${GREEN}✅ systemd 单元已生成并启用开机自启:${NC}"
echo " - $SYSTEMD_BACKEND.service"
echo " - $SYSTEMD_CELERY_WORKER.service"
echo " - $SYSTEMD_CELERY_BEAT.service"
echo ""
}
# 启动后端(systemd
start_backend() {
echo "🚀 启动后端 API 服务..."
# 检查虚拟环境
if [ ! -d "$BACKEND_DIR/venv" ]; then
echo -e "${YELLOW}创建 Python 虚拟环境...${NC}"
cd $BACKEND_DIR
python3 -m venv venv
fi
# 激活虚拟环境并启动
cd $BACKEND_DIR
source venv/bin/activate
# 检查端口并清理
# 检查并清理占用端口的旧进程
kill_port $API_PORT
# 后台启动 uvicorn
nohup uvicorn app.main:app --host 0.0.0.0 --port $API_PORT > /tmp/ipam-backend.log 2>&1 &
BACKEND_PID=$!
# 等待服务启动
sleep 5
# 检查是否启动成功
# 通过 systemd 启动
systemctl restart $SYSTEMD_BACKEND
sleep 4
if curl -s http://localhost:$API_PORT/health > /dev/null; then
echo -e "${GREEN}✅ 后端 API 启动成功 (端口 $API_PORT)${NC}"
echo " PID: $BACKEND_PID"
echo " 日志: /tmp/ipam-backend.log"
echo -e "${GREEN}✅ 后端 API 启动成功 (端口 $API_PORT, systemd 管理)${NC}"
return 0
else
echo -e "${RED}❌ 后端 API 启动失败${NC}"
echo " 查看日志: tail -50 /tmp/ipam-backend.log"
echo " 查看日志: journalctl -u $SYSTEMD_BACKEND -n 50 或 tail -50 $LOG_DIR/ipam-backend.log"
return 1
fi
echo ""
}
# 启动 Celery Worker
# 启动 Celerysystemdworker + beat
start_celery() {
echo "🚀 启动 Celery Worker..."
cd $BACKEND_DIR
source venv/bin/activate
# 检查是否已有 celery 进程
pkill -f "celery.*app.tasks.celery_app" 2>/dev/null
sleep 1
# 后台启动 celery
nohup celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4 > /tmp/ipam-celery.log 2>&1 &
CELERY_PID=$!
sleep 3
if ps -p $CELERY_PID > /dev/null; then
echo -e "${GREEN}✅ Celery Worker 启动成功${NC}"
echo " PID: $CELERY_PID"
echo " 日志: /tmp/ipam-celery.log"
echo "🚀 启动 Celery Worker & Beat (systemd)..."
systemctl restart $SYSTEMD_CELERY_WORKER
systemctl restart $SYSTEMD_CELERY_BEAT
sleep 4
systemctl is-active --quiet $SYSTEMD_CELERY_WORKER && WORKER_ACTIVE="yes" || WORKER_ACTIVE="no"
systemctl is-active --quiet $SYSTEMD_CELERY_BEAT && BEAT_ACTIVE="yes" || BEAT_ACTIVE="no"
if [ "$WORKER_ACTIVE" = "yes" ] && [ "$BEAT_ACTIVE" = "yes" ]; then
echo -e "${GREEN}✅ Celery Worker & Beat 启动成功 (systemd 管理)${NC}"
return 0
else
echo -e "${RED}❌ Celery Worker 启动失败${NC}"
echo " 查看日志: tail -50 /tmp/ipam-celery.log"
echo -e "${RED}❌ Celery 启动异常 (worker=$WORKER_ACTIVE beat=$BEAT_ACTIVE)${NC}"
echo " 查看日志: journalctl -u $SYSTEMD_CELERY_WORKER -n 30 -u $SYSTEMD_CELERY_BEAT -n 30"
return 1
fi
echo ""
}
# 启动前端
# 启动前端nohup,开发服务)
start_frontend() {
echo "🚀 启动前端服务..."
cd $FRONTEND_DIR
# 检查 node_modules
if [ ! -d "node_modules" ]; then
echo -e "${YELLOW}安装前端依赖...${NC}"
npm install
# 检查 node_modules 及关键依赖是否完整(含 vite,全局 npm 可能配置了 omit=dev
if [ ! -d "node_modules" ] || [ ! -f "node_modules/vite/bin/vite.js" ]; then
echo -e "${YELLOW}安装前端依赖(含 devDependencies,保证 vite 可用)...${NC}"
npm install --include=dev
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
# 清理 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 &
FRONTEND_PID=$!
sleep 8
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
echo -e "${YELLOW}首次启动失败,尝试清理 Vite 缓存重试...${NC}"
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
echo ""
}
@@ -176,8 +300,9 @@ show_status() {
echo "📊 服务状态:"
echo " - MySQL: 127.0.0.1:3308 ✅"
echo " - Redis: 127.0.0.1:6379 ✅"
echo " - 后端 API: http://$(hostname -I | awk '{print $1}'):$API_PORT"
echo " - Celery: 后台运行 ✅"
echo " - 后端 API: http://$(hostname -I | awk '{print $1}'):$API_PORT (systemd: $SYSTEMD_BACKEND)"
echo " - Celery Worker: systemd: $SYSTEMD_CELERY_WORKER"
echo " - Celery Beat: systemd: $SYSTEMD_CELERY_BEAT ✅(每60秒触发SNMP设备自动轮询)"
echo " - 前端界面: http://$(hostname -I | awk '{print $1}'):$FRONTEND_PORT"
echo ""
echo "📖 访问地址:"
@@ -185,7 +310,11 @@ show_status() {
echo " - API 文档: http://$(hostname -I | awk '{print $1}'):$API_PORT/docs"
echo ""
echo "🛑 停止服务命令:"
echo " pkill -f 'uvicorn|celery|vite'"
echo " bash $SCRIPT_DIR/stop.sh"
echo " 或 systemctl stop $SYSTEMD_CELERY_BEAT $SYSTEMD_CELERY_WORKER $SYSTEMD_BACKEND"
echo ""
echo "📈 查看状态:systemctl status $SYSTEMD_BACKEND $SYSTEMD_CELERY_WORKER $SYSTEMD_CELERY_BEAT"
echo "📈 查看日志:journalctl -u $SYSTEMD_CELERY_BEAT -f"
echo ""
}
@@ -193,7 +322,7 @@ show_status() {
main() {
# 检查是否是 root 用户
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}请使用 root 权限运行此脚本${NC}"
echo -e "${RED}请使用 root 权限运行此脚本(生成 systemd 单元需要)${NC}"
exit 1
fi
@@ -217,6 +346,7 @@ main() {
echo ""
check_docker_containers
setup_systemd_services
start_backend
start_celery
start_frontend
+37 -16
View File
@@ -1,5 +1,8 @@
#!/bin/bash
# IPAM 管理系统停止脚本
#
# 通过 systemd 停止后端与 Celery(若服务已接入 systemd),
# 并停止前端 dev server。
echo "=========================================="
echo " IPAM 管理系统停止脚本"
@@ -12,37 +15,52 @@ GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 目录自适应(跟随本脚本所在目录)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_DIR="$SCRIPT_DIR"
# systemd 服务名(与 start.sh 保持一致)
SYSTEMD_BACKEND="ipam-backend"
SYSTEMD_CELERY_WORKER="ipam-celery-worker"
SYSTEMD_CELERY_BEAT="ipam-celery-beat"
echo "🛑 正在停止所有服务..."
echo ""
# 停止后端
echo "停止后端 API 服务..."
pkill -f "uvicorn app.main:app" 2>/dev/null
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ 后端已停止${NC}"
# 停止 Celery Beat
echo "停止 Celery Beat..."
if systemctl stop $SYSTEMD_CELERY_BEAT 2>/dev/null; then
echo -e "${GREEN}✅ Celery Beat 已停止 (systemd)${NC}"
else
echo -e "${YELLOW}️ 后端未运行${NC}"
pkill -f "celery.*app.tasks.celery_app.*beat" 2>/dev/null
echo -e "${YELLOW}️ Celery Beat 已停止 (pkill 兜底)${NC}"
fi
# 停止 Celery
# 停止 Celery Worker
echo ""
echo "停止 Celery Worker..."
pkill -f "celery.*app.tasks.celery_app" 2>/dev/null
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ Celery Worker 已停止${NC}"
if systemctl stop $SYSTEMD_CELERY_WORKER 2>/dev/null; then
echo -e "${GREEN}✅ Celery Worker 已停止 (systemd)${NC}"
else
echo -e "${YELLOW}️ Celery Worker 未运行${NC}"
pkill -f "celery.*app.tasks.celery_app" 2>/dev/null
echo -e "${YELLOW}️ Celery Worker 已停止 (pkill 兜底)${NC}"
fi
# 停止后端
echo ""
echo "停止后端 API 服务..."
if systemctl stop $SYSTEMD_BACKEND 2>/dev/null; then
echo -e "${GREEN}✅ 后端已停止 (systemd)${NC}"
else
pkill -f "uvicorn app.main:app" 2>/dev/null
echo -e "${YELLOW}ℹ️ 后端已停止 (pkill 兜底)${NC}"
fi
# 停止前端
echo ""
echo "停止前端服务..."
pkill -f "vite" 2>/dev/null
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ 前端服务已停止${NC}"
else
echo -e "${YELLOW}️ 前端服务未运行${NC}"
fi
echo -e "${GREEN}✅ 前端服务已停止${NC}"
echo ""
echo "=========================================="
@@ -52,3 +70,6 @@ echo ""
echo "📋 剩余进程检查:"
ps aux | grep -E "(uvicorn|celery|vite)" | grep -v grep | awk '{print $2, $11}'
echo ""
echo "💡 若希望永久停止开机自启,可执行:"
echo " systemctl disable $SYSTEMD_BACKEND $SYSTEMD_CELERY_WORKER $SYSTEMD_CELERY_BEAT"
echo ""