Compare commits
21 Commits
ipam-0.0.3
...
ipam_0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 365ab73e7d | |||
| 717fa31456 | |||
| 3fd6ba3baf | |||
| 18cefa6ded | |||
| 5369d8d6e5 | |||
| 8775c9836d | |||
| b7551af688 | |||
| 11d830b496 | |||
| 934b604b65 | |||
| 13c457acb3 | |||
| 05cd71ca1a | |||
| ee0552f837 | |||
| 094d2cc112 | |||
| 8b1df674ec | |||
| 6076e174d5 | |||
| 8a94f535de | |||
| ebb04f9ad8 | |||
| f66e4d41c9 | |||
| e0cba105b6 | |||
| a8e764b102 | |||
| 5b93e4536f |
@@ -22,6 +22,10 @@ dist/
|
|||||||
*.log
|
*.log
|
||||||
/tmp/
|
/tmp/
|
||||||
|
|
||||||
|
# Celery Beat 调度状态文件(运行期自动生成)
|
||||||
|
celerybeat-schedule*
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -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')"
|
||||||
|
```
|
||||||
@@ -53,46 +53,55 @@
|
|||||||
### 一键启动所有服务
|
### 一键启动所有服务
|
||||||
|
|
||||||
#### 方式一:使用启动脚本(推荐)
|
#### 方式一:使用启动脚本(推荐)
|
||||||
|
|
||||||
|
`start.sh` 目录自适应,无论部署在 `/opt`、`/usr/local`、`/root` 还是其它任意目录,
|
||||||
|
直接以脚本所在目录为项目根运行即可(需 root 权限):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /root/ipam
|
cd /你的部署目录 # 例如 /opt/ipam,跟随你实际的部署路径
|
||||||
./start.sh
|
bash start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
运行时会自动:创建 Python venv → 安装后端依赖 → 启动 MySQL/Redis 容器 →
|
||||||
|
生成 3 个 systemd 服务(backend / celery-worker / celery-beat)并 enable 开机自启 →
|
||||||
|
启动前端。更多细节见下文「生产环境部署建议」。
|
||||||
|
|
||||||
#### 方式二:手动启动
|
#### 方式二:手动启动
|
||||||
|
|
||||||
|
> ⚠️ 仅供开发调试用。生产环境推荐使用方式一(start.sh),可自动生成 systemd 服务、开机自启、崩溃自动重启。
|
||||||
|
|
||||||
#### 安装依赖环境
|
#### 安装依赖环境
|
||||||
```bash
|
```bash
|
||||||
cd /root/ipam/backend
|
# 以下 <项目目录> 均指你实际的部署路径,例如 /opt/ipam
|
||||||
|
cd <项目目录>/backend
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install "pysnmp==4.4.12" "pyasn1<0.5.0" "pysmi<0.4.0"
|
pip install "pysnmp==4.4.12" "pyasn1<0.5.0" "pysmi<0.4.0"
|
||||||
apt install -y celery
|
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 1️⃣ 启动后端 API 服务
|
##### 1️⃣ 启动后端 API 服务
|
||||||
```bash
|
```bash
|
||||||
cd /root/ipam/backend
|
cd <项目目录>/backend
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8008
|
uvicorn app.main:app --host 0.0.0.0 --port 8008
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2️⃣ 启动 Celery Worker(执行扫描任务)
|
##### 2️⃣ 启动 Celery Worker(执行扫描任务)
|
||||||
```bash
|
```bash
|
||||||
cd /root/ipam/backend
|
cd <项目目录>/backend
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
|
celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 3️⃣ 启动 Celery Beat(定时任务调度器,自动扫描必须启动)
|
##### 3️⃣ 启动 Celery Beat(定时任务调度器,自动扫描必须启动)
|
||||||
```bash
|
```bash
|
||||||
cd /root/ipam/backend
|
cd <项目目录>/backend
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
celery -A app.tasks.celery_app beat --loglevel=info
|
celery -A app.tasks.celery_app beat --loglevel=info
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 4️⃣ 启动前端
|
##### 4️⃣ 启动前端
|
||||||
```bash
|
```bash
|
||||||
cd /root/ipam/frontend
|
cd <项目目录>/frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev -- --host 0.0.0.0 --port 3000
|
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
|
```python
|
||||||
# 定时任务配置 (第 36-55 行)
|
# 定时任务配置 (第 36-55 行)
|
||||||
@@ -169,7 +178,7 @@ celery_app.conf.beat_schedule = {
|
|||||||
|
|
||||||
#### 查看 Celery 活跃任务
|
#### 查看 Celery 活跃任务
|
||||||
```bash
|
```bash
|
||||||
cd /root/ipam/backend
|
cd <项目目录>/backend
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
celery -A app.tasks.celery_app inspect active
|
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 服务
|
`start.sh` 已内置完整的生产级部署逻辑,**无需手动创建任何 systemd 单元**:
|
||||||
```ini
|
|
||||||
# /etc/systemd/system/ipam-api.service
|
|
||||||
[Unit]
|
|
||||||
Description=IPAM API Service
|
|
||||||
After=network.target mysql.service redis.service
|
|
||||||
|
|
||||||
[Service]
|
- **目录自适应**:不硬编码路径,自动以脚本所在目录为项目根。无论部署在
|
||||||
Type=simple
|
`/opt/ipam`、`/usr/local/ipam`、`/root/ipam` 还是其它任意目录都能工作。
|
||||||
User=root
|
- **systemd 自动生成**:运行 `start.sh` 时自动生成并 `systemctl enable` 如下
|
||||||
WorkingDirectory=/root/ipam/backend
|
3 个服务,实现**开机自启** + **崩溃自动重启**(`Restart=always`):
|
||||||
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
|
|
||||||
|
|
||||||
[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 服务
|
日志写入 `/var/log/ipam/*.log`。
|
||||||
```ini
|
|
||||||
# /etc/systemd/system/ipam-celery-worker.service
|
|
||||||
[Unit]
|
|
||||||
Description=IPAM Celery Worker
|
|
||||||
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 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
|
```bash
|
||||||
# 重新加载配置
|
# 1. 前置:Python3 / Node.js / Docker 需已安装
|
||||||
systemctl daemon-reload
|
# 2. git clone 代码(放到你想要的任何目录)
|
||||||
|
git clone https://git.cnbugs.com/AI-Agent/ipam.git /opt/ipam
|
||||||
|
cd /opt/ipam
|
||||||
|
|
||||||
# 启用开机自启
|
# 3. 一键启动(需 root 权限,自动装依赖 + 起 MySQL/Redis 容器 + 生成 systemd + 启前端)
|
||||||
systemctl enable ipam-api ipam-celery-worker ipam-celery-beat
|
bash start.sh
|
||||||
|
|
||||||
# 启动服务
|
|
||||||
systemctl start ipam-api ipam-celery-worker ipam-celery-beat
|
|
||||||
|
|
||||||
# 查看服务状态
|
|
||||||
systemctl status ipam-api ipam-celery-worker ipam-celery-beat
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
启动成功后即可访问:
|
||||||
|
- 管理界面 `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/
|
├── backend/
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── api/v1/ # API 路由
|
│ │ ├── api/v1/ # API 路由
|
||||||
|
|||||||
@@ -17,3 +17,16 @@ SECRET_KEY=your-super-secret-key-here-change-in-production
|
|||||||
PING_TIMEOUT=2
|
PING_TIMEOUT=2
|
||||||
PING_RETRIES=2
|
PING_RETRIES=2
|
||||||
SCAN_CONCURRENCY=50
|
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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="确认告警")
|
||||||
@@ -180,3 +181,91 @@ def detect_offline_devices(db: Session = Depends(get_db)):
|
|||||||
"count": len(offline_devices),
|
"count": len(offline_devices),
|
||||||
"devices": 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 地址和日志")
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ def comprehensive_scan_ip(
|
|||||||
EnhancedScanService.update_ip_from_scan_result(db, result)
|
EnhancedScanService.update_ip_from_scan_result(db, result)
|
||||||
|
|
||||||
# 刷新所属网段统计字段(used_ips/reserved_ips),保证网段列表利用率能即时反映
|
# 刷新所属网段统计字段(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:
|
if db_ip:
|
||||||
NetworkService.get_stats(db, db_ip.network_id)
|
NetworkService.get_stats(db, db_ip.network_id)
|
||||||
|
|
||||||
|
|||||||
+32
-37
@@ -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,
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ class Settings(BaseSettings):
|
|||||||
PING_RETRIES: int = 2
|
PING_RETRIES: int = 2
|
||||||
SCAN_CONCURRENCY: int = 50
|
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:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ from app.models.snmp import SNMPCredential, NetworkDevice, ARPEntry, MACAddressT
|
|||||||
from app.models.alert import Alert, AlertRule, WhitelistedMAC
|
from app.models.alert import Alert, AlertRule, WhitelistedMAC
|
||||||
from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken
|
from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.notification import NotificationConfig
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|||||||
@@ -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
|
||||||
Binary file not shown.
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
统一时间格式化工具
|
||||||
|
|
||||||
|
背景:MySQL DATETIME 不带时区,backend 用 datetime.utcnow() 写入 UTC naive datetime。
|
||||||
|
原 schema 直接 .isoformat() 输出,前端拿到无时区字符串后当成 Asia/Shanghai 本地时间显示,
|
||||||
|
导致所有时间统一少 8 小时。
|
||||||
|
|
||||||
|
本工具把任意 datetime(naive 或 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 转成 dict,datetime 字段自动用业务时区 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,6 @@
|
|||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
@@ -11,6 +12,12 @@ from app.models.alert import (
|
|||||||
from app.models.network import Network, IPAddress
|
from app.models.network import Network, IPAddress
|
||||||
from app.models.snmp import NetworkDevice, ARPEntry
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +76,22 @@ class AlertService:
|
|||||||
db.refresh(alert)
|
db.refresh(alert)
|
||||||
|
|
||||||
logger.info(f"创建告警: {alert_type} - {title}")
|
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
|
return alert
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -80,7 +103,7 @@ class AlertService:
|
|||||||
subquery = db.query(
|
subquery = db.query(
|
||||||
ARPEntry.ip_address
|
ARPEntry.ip_address
|
||||||
).group_by(ARPEntry.ip_address).having(
|
).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()
|
).subquery()
|
||||||
|
|
||||||
conflict_entries = db.query(ARPEntry).filter(
|
conflict_entries = db.query(ARPEntry).filter(
|
||||||
|
|||||||
@@ -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 不可用时使用)
|
||||||
@@ -384,17 +429,7 @@ class EnhancedScanService:
|
|||||||
|
|
||||||
# 如果IP不存在,尝试自动创建
|
# 如果IP不存在,尝试自动创建
|
||||||
if not db_ip:
|
if not db_ip:
|
||||||
network = db.query(Network).filter(
|
network = EnhancedScanService._find_network_for_ip(db, ip_address)
|
||||||
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
|
|
||||||
if not network:
|
if not network:
|
||||||
logger.warning(f"无法为扫描到的IP {ip_address} 找到所属网段,跳过创建")
|
logger.warning(f"无法为扫描到的IP {ip_address} 找到所属网段,跳过创建")
|
||||||
return
|
return
|
||||||
@@ -436,6 +471,31 @@ class EnhancedScanService:
|
|||||||
|
|
||||||
return db_ip
|
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
|
@staticmethod
|
||||||
def bulk_update_ips_from_scan(db: Session, scan_results: List[Dict[str, Any]]):
|
def bulk_update_ips_from_scan(db: Session, scan_results: List[Dict[str, Any]]):
|
||||||
"""批量更新IP信息"""
|
"""批量更新IP信息"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
"""用一组值构造 FeishuService(api 层用)"""
|
||||||
|
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)
|
||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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,22 +179,21 @@ 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:
|
||||||
|
|
||||||
if errorStatus:
|
|
||||||
logger.error(f"SNMP 错误: {errorStatus}")
|
logger.error(f"SNMP 错误: {errorStatus}")
|
||||||
break
|
else:
|
||||||
|
# varBindTable 是列表的列表,每行是一条 SNMP 响应记录
|
||||||
|
for varBinds in varBindTable:
|
||||||
# 解析结果
|
# 解析结果
|
||||||
arp_data = {}
|
arp_data = {}
|
||||||
for varBind in varBinds:
|
for varBind in varBinds:
|
||||||
@@ -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:
|
||||||
|
if not ip_addr.mac_address:
|
||||||
ip_addr.mac_address = entry['mac_address']
|
ip_addr.mac_address = entry['mac_address']
|
||||||
# 识别厂商
|
# 始终尝试补全厂商(有 MAC 但没厂商时也需要)
|
||||||
ip_addr.vendor = EnhancedScanService.get_mac_vendor(entry['mac_address'])
|
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,19 +299,19 @@ 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:
|
||||||
if errorStatus:
|
logger.error(f"获取 MAC 地址表失败: {errorStatus}")
|
||||||
break
|
else:
|
||||||
|
for varBinds in varBindTable:
|
||||||
for varBind in varBinds:
|
for varBind in varBinds:
|
||||||
oid = str(varBind[0])
|
oid = str(varBind[0])
|
||||||
value = varBind[1]
|
value = varBind[1]
|
||||||
@@ -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,10 +392,9 @@ 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:
|
for varBind in varBinds:
|
||||||
oid = str(varBind[0])
|
oid = str(varBind[0])
|
||||||
value = varBind[1]
|
value = varBind[1]
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ scan_single_ip_task = _tasks['scan_single_ip_task']
|
|||||||
full_network_scan = _tasks['full_network_scan']
|
full_network_scan = _tasks['full_network_scan']
|
||||||
update_all_statistics = _tasks['update_all_statistics']
|
update_all_statistics = _tasks['update_all_statistics']
|
||||||
quick_status_update = _tasks['quick_status_update']
|
quick_status_update = _tasks['quick_status_update']
|
||||||
|
poll_snmp_devices = _tasks['poll_snmp_devices']
|
||||||
|
|
||||||
# 定时任务配置
|
# 定时任务配置
|
||||||
celery_app.conf.beat_schedule = {
|
celery_app.conf.beat_schedule = {
|
||||||
@@ -52,4 +53,9 @@ celery_app.conf.beat_schedule = {
|
|||||||
'task': 'app.tasks.scan_tasks.update_all_statistics',
|
'task': 'app.tasks.scan_tasks.update_all_statistics',
|
||||||
'schedule': 900.0, # 15分钟
|
'schedule': 900.0, # 15分钟
|
||||||
},
|
},
|
||||||
|
# 每60秒检查一次 SNMP 设备轮询(各设备按自己的 poll_interval 判断是否到期)
|
||||||
|
'poll-snmp-devices-every-60s': {
|
||||||
|
'task': 'app.tasks.scan_tasks.poll_snmp_devices',
|
||||||
|
'schedule': 60.0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,11 +212,75 @@ def register_tasks(celery_app):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
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 {
|
return {
|
||||||
'scan_network_task': scan_network_task,
|
'scan_network_task': scan_network_task,
|
||||||
'scan_single_ip_task': scan_single_ip_task,
|
'scan_single_ip_task': scan_single_ip_task,
|
||||||
'full_network_scan': full_network_scan,
|
'full_network_scan': full_network_scan,
|
||||||
'update_all_statistics': update_all_statistics,
|
'update_all_statistics': update_all_statistics,
|
||||||
'quick_status_update': quick_status_update
|
'quick_status_update': quick_status_update,
|
||||||
|
'poll_snmp_devices': poll_snmp_devices,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -131,6 +131,19 @@ export const alertApi = {
|
|||||||
},
|
},
|
||||||
removeFromWhitelist(id) {
|
removeFromWhitelist(id) {
|
||||||
return api.delete(`/alerts/whitelist/macs/${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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* 统一时间格式化工具
|
||||||
|
*
|
||||||
|
* 背景:后端用 datetime.utcnow() 写入 UTC naive datetime。
|
||||||
|
* 修复后端后,schema 输出会带 +08:00 或 Z(Asia/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}`
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
<el-icon><Check /></el-icon>
|
<el-icon><Check /></el-icon>
|
||||||
全部确认
|
全部确认
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button @click="openFeishuDialog">
|
||||||
|
<el-icon><ChatDotRound /></el-icon>
|
||||||
|
飞书通知设置
|
||||||
|
</el-button>
|
||||||
</el-button-group>
|
</el-button-group>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -141,7 +145,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 +198,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">
|
||||||
@@ -231,6 +235,47 @@
|
|||||||
<el-button type="primary" @click="addToWhitelist" :loading="submitting">确定</el-button>
|
<el-button type="primary" @click="addToWhitelist" :loading="submitting">确定</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -238,6 +283,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,9 +506,67 @@ 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')
|
// ---- 飞书通知配置 ----
|
||||||
|
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(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# IPAM 管理系统一键启动脚本
|
# 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 "=========================================="
|
||||||
echo " IPAM 管理系统启动脚本"
|
echo " IPAM 管理系统启动脚本"
|
||||||
@@ -12,8 +20,10 @@ GREEN='\033[0;32m'
|
|||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m' # No Color
|
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"
|
BACKEND_DIR="$BASE_DIR/backend"
|
||||||
FRONTEND_DIR="$BASE_DIR/frontend"
|
FRONTEND_DIR="$BASE_DIR/frontend"
|
||||||
|
|
||||||
@@ -21,6 +31,17 @@ FRONTEND_DIR="$BASE_DIR/frontend"
|
|||||||
API_PORT=8008
|
API_PORT=8008
|
||||||
FRONTEND_PORT=3000
|
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 容器状态
|
# 检查 Docker 容器状态
|
||||||
check_docker_containers() {
|
check_docker_containers() {
|
||||||
echo "🔍 检查 Docker 容器..."
|
echo "🔍 检查 Docker 容器..."
|
||||||
@@ -31,10 +52,10 @@ check_docker_containers() {
|
|||||||
docker start ipam-mysql 2>/dev/null || {
|
docker start ipam-mysql 2>/dev/null || {
|
||||||
echo -e "${YELLOW}创建 MySQL 容器...${NC}"
|
echo -e "${YELLOW}创建 MySQL 容器...${NC}"
|
||||||
docker run -d --name ipam-mysql -p 3308:3306 \
|
docker run -d --name ipam-mysql -p 3308:3306 \
|
||||||
-e MYSQL_ROOT_PASSWORD=*** \
|
-e MYSQL_ROOT_PASSWORD=ipam2024 \
|
||||||
-e MYSQL_DATABASE=ipam \
|
-e MYSQL_DATABASE=ipam \
|
||||||
-e MYSQL_USER=ipam \
|
-e MYSQL_USER=ipam \
|
||||||
-e MYSQL_PASSWORD=*** \
|
-e MYSQL_PASSWORD=ipam2024 \
|
||||||
mysql:8.0 --default-authentication-plugin=mysql_native_password
|
mysql:8.0 --default-authentication-plugin=mysql_native_password
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
@@ -63,7 +84,90 @@ kill_port() {
|
|||||||
fi
|
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() {
|
start_backend() {
|
||||||
echo "🚀 启动后端 API 服务..."
|
echo "🚀 启动后端 API 服务..."
|
||||||
|
|
||||||
@@ -74,85 +178,104 @@ start_backend() {
|
|||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 激活虚拟环境并启动
|
# 检查并清理占用端口的旧进程
|
||||||
cd $BACKEND_DIR
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
# 检查端口并清理
|
|
||||||
kill_port $API_PORT
|
kill_port $API_PORT
|
||||||
|
|
||||||
# 后台启动 uvicorn
|
# 通过 systemd 启动
|
||||||
nohup uvicorn app.main:app --host 0.0.0.0 --port $API_PORT > /tmp/ipam-backend.log 2>&1 &
|
systemctl restart $SYSTEMD_BACKEND
|
||||||
BACKEND_PID=$!
|
sleep 4
|
||||||
|
|
||||||
# 等待服务启动
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# 检查是否启动成功
|
|
||||||
if curl -s http://localhost:$API_PORT/health > /dev/null; then
|
if curl -s http://localhost:$API_PORT/health > /dev/null; then
|
||||||
echo -e "${GREEN}✅ 后端 API 启动成功 (端口 $API_PORT)${NC}"
|
echo -e "${GREEN}✅ 后端 API 启动成功 (端口 $API_PORT, systemd 管理)${NC}"
|
||||||
echo " PID: $BACKEND_PID"
|
|
||||||
echo " 日志: /tmp/ipam-backend.log"
|
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
echo -e "${RED}❌ 后端 API 启动失败${NC}"
|
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
|
return 1
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 启动 Celery Worker
|
# 启动 Celery(systemd:worker + beat)
|
||||||
start_celery() {
|
start_celery() {
|
||||||
echo "🚀 启动 Celery Worker..."
|
echo "🚀 启动 Celery Worker & Beat (systemd)..."
|
||||||
|
systemctl restart $SYSTEMD_CELERY_WORKER
|
||||||
|
systemctl restart $SYSTEMD_CELERY_BEAT
|
||||||
|
sleep 4
|
||||||
|
|
||||||
cd $BACKEND_DIR
|
systemctl is-active --quiet $SYSTEMD_CELERY_WORKER && WORKER_ACTIVE="yes" || WORKER_ACTIVE="no"
|
||||||
source venv/bin/activate
|
systemctl is-active --quiet $SYSTEMD_CELERY_BEAT && BEAT_ACTIVE="yes" || BEAT_ACTIVE="no"
|
||||||
|
|
||||||
# 检查是否已有 celery 进程
|
if [ "$WORKER_ACTIVE" = "yes" ] && [ "$BEAT_ACTIVE" = "yes" ]; then
|
||||||
pkill -f "celery.*app.tasks.celery_app" 2>/dev/null
|
echo -e "${GREEN}✅ Celery Worker & Beat 启动成功 (systemd 管理)${NC}"
|
||||||
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"
|
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
echo -e "${RED}❌ Celery Worker 启动失败${NC}"
|
echo -e "${RED}❌ Celery 启动异常 (worker=$WORKER_ACTIVE beat=$BEAT_ACTIVE)${NC}"
|
||||||
echo " 查看日志: tail -50 /tmp/ipam-celery.log"
|
echo " 查看日志: journalctl -u $SYSTEMD_CELERY_WORKER -n 30 -u $SYSTEMD_CELERY_BEAT -n 30"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 启动前端
|
# 启动前端(nohup,开发服务)
|
||||||
start_frontend() {
|
start_frontend() {
|
||||||
echo "🚀 启动前端服务..."
|
echo "🚀 启动前端服务..."
|
||||||
|
|
||||||
cd $FRONTEND_DIR
|
cd $FRONTEND_DIR
|
||||||
|
|
||||||
# 检查 node_modules
|
# 检查 node_modules 及关键依赖是否完整(含 vite,全局 npm 可能配置了 omit=dev)
|
||||||
if [ ! -d "node_modules" ]; then
|
if [ ! -d "node_modules" ] || [ ! -f "node_modules/vite/bin/vite.js" ]; then
|
||||||
echo -e "${YELLOW}安装前端依赖...${NC}"
|
echo -e "${YELLOW}安装前端依赖(含 devDependencies,保证 vite 可用)...${NC}"
|
||||||
npm install
|
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
|
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=$!
|
||||||
|
|
||||||
sleep 8
|
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 "${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
|
if curl -s http://localhost:$FRONTEND_PORT > /dev/null; then
|
||||||
echo -e "${GREEN}✅ 前端服务启动成功 (端口 $FRONTEND_PORT)${NC}"
|
echo -e "${GREEN}✅ 前端服务启动成功 (端口 $FRONTEND_PORT)${NC}"
|
||||||
echo " PID: $FRONTEND_PID"
|
echo " PID: $FRONTEND_PID"
|
||||||
@@ -163,6 +286,7 @@ start_frontend() {
|
|||||||
echo " 查看日志: tail -50 /tmp/ipam-frontend.log"
|
echo " 查看日志: tail -50 /tmp/ipam-frontend.log"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,8 +300,9 @@ show_status() {
|
|||||||
echo "📊 服务状态:"
|
echo "📊 服务状态:"
|
||||||
echo " - MySQL: 127.0.0.1:3308 ✅"
|
echo " - MySQL: 127.0.0.1:3308 ✅"
|
||||||
echo " - Redis: 127.0.0.1:6379 ✅"
|
echo " - Redis: 127.0.0.1:6379 ✅"
|
||||||
echo " - 后端 API: http://$(hostname -I | awk '{print $1}'):$API_PORT ✅"
|
echo " - 后端 API: http://$(hostname -I | awk '{print $1}'):$API_PORT ✅ (systemd: $SYSTEMD_BACKEND)"
|
||||||
echo " - Celery: 后台运行 ✅"
|
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 " - 前端界面: http://$(hostname -I | awk '{print $1}'):$FRONTEND_PORT ✅"
|
||||||
echo ""
|
echo ""
|
||||||
echo "📖 访问地址:"
|
echo "📖 访问地址:"
|
||||||
@@ -185,7 +310,11 @@ show_status() {
|
|||||||
echo " - API 文档: http://$(hostname -I | awk '{print $1}'):$API_PORT/docs"
|
echo " - API 文档: http://$(hostname -I | awk '{print $1}'):$API_PORT/docs"
|
||||||
echo ""
|
echo ""
|
||||||
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 ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +322,7 @@ show_status() {
|
|||||||
main() {
|
main() {
|
||||||
# 检查是否是 root 用户
|
# 检查是否是 root 用户
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$EUID" -ne 0 ]; then
|
||||||
echo -e "${RED}请使用 root 权限运行此脚本${NC}"
|
echo -e "${RED}请使用 root 权限运行此脚本(生成 systemd 单元需要)${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -217,6 +346,7 @@ main() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
check_docker_containers
|
check_docker_containers
|
||||||
|
setup_systemd_services
|
||||||
start_backend
|
start_backend
|
||||||
start_celery
|
start_celery
|
||||||
start_frontend
|
start_frontend
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# IPAM 管理系统停止脚本
|
# IPAM 管理系统停止脚本
|
||||||
|
#
|
||||||
|
# 通过 systemd 停止后端与 Celery(若服务已接入 systemd),
|
||||||
|
# 并停止前端 dev server。
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo " IPAM 管理系统停止脚本"
|
echo " IPAM 管理系统停止脚本"
|
||||||
@@ -12,37 +15,52 @@ GREEN='\033[0;32m'
|
|||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m' # No Color
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# 停止后端
|
# 停止 Celery Beat
|
||||||
echo "停止后端 API 服务..."
|
echo "停止 Celery Beat..."
|
||||||
pkill -f "uvicorn app.main:app" 2>/dev/null
|
if systemctl stop $SYSTEMD_CELERY_BEAT 2>/dev/null; then
|
||||||
if [ $? -eq 0 ]; then
|
echo -e "${GREEN}✅ Celery Beat 已停止 (systemd)${NC}"
|
||||||
echo -e "${GREEN}✅ 后端已停止${NC}"
|
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW}ℹ️ 后端未运行${NC}"
|
pkill -f "celery.*app.tasks.celery_app.*beat" 2>/dev/null
|
||||||
|
echo -e "${YELLOW}ℹ️ Celery Beat 已停止 (pkill 兜底)${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 停止 Celery
|
# 停止 Celery Worker
|
||||||
echo ""
|
echo ""
|
||||||
echo "停止 Celery Worker..."
|
echo "停止 Celery Worker..."
|
||||||
pkill -f "celery.*app.tasks.celery_app" 2>/dev/null
|
if systemctl stop $SYSTEMD_CELERY_WORKER 2>/dev/null; then
|
||||||
if [ $? -eq 0 ]; then
|
echo -e "${GREEN}✅ Celery Worker 已停止 (systemd)${NC}"
|
||||||
echo -e "${GREEN}✅ Celery Worker 已停止${NC}"
|
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
# 停止前端
|
# 停止前端
|
||||||
echo ""
|
echo ""
|
||||||
echo "停止前端服务..."
|
echo "停止前端服务..."
|
||||||
pkill -f "vite" 2>/dev/null
|
pkill -f "vite" 2>/dev/null
|
||||||
if [ $? -eq 0 ]; then
|
echo -e "${GREEN}✅ 前端服务已停止${NC}"
|
||||||
echo -e "${GREEN}✅ 前端服务已停止${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}ℹ️ 前端服务未运行${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
@@ -52,3 +70,6 @@ echo ""
|
|||||||
echo "📋 剩余进程检查:"
|
echo "📋 剩余进程检查:"
|
||||||
ps aux | grep -E "(uvicorn|celery|vite)" | grep -v grep | awk '{print $2, $11}'
|
ps aux | grep -E "(uvicorn|celery|vite)" | grep -v grep | awk '{print $2, $11}'
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "💡 若希望永久停止开机自启,可执行:"
|
||||||
|
echo " systemctl disable $SYSTEMD_BACKEND $SYSTEMD_CELERY_WORKER $SYSTEMD_CELERY_BEAT"
|
||||||
|
echo ""
|
||||||
|
|||||||
Reference in New Issue
Block a user