Compare commits
7 Commits
python3.10
...
ipam_0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 365ab73e7d | |||
| 717fa31456 | |||
| 3fd6ba3baf | |||
| 18cefa6ded | |||
| 5369d8d6e5 | |||
| 8775c9836d | |||
| b7551af688 |
@@ -22,6 +22,10 @@ dist/
|
||||
*.log
|
||||
/tmp/
|
||||
|
||||
# Celery Beat 调度状态文件(运行期自动生成)
|
||||
celerybeat-schedule*
|
||||
celerybeat.pid
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -53,46 +53,55 @@
|
||||
### 一键启动所有服务
|
||||
|
||||
#### 方式一:使用启动脚本(推荐)
|
||||
|
||||
`start.sh` 目录自适应,无论部署在 `/opt`、`/usr/local`、`/root` 还是其它任意目录,
|
||||
直接以脚本所在目录为项目根运行即可(需 root 权限):
|
||||
|
||||
```bash
|
||||
cd /root/ipam
|
||||
./start.sh
|
||||
cd /你的部署目录 # 例如 /opt/ipam,跟随你实际的部署路径
|
||||
bash start.sh
|
||||
```
|
||||
|
||||
运行时会自动:创建 Python venv → 安装后端依赖 → 启动 MySQL/Redis 容器 →
|
||||
生成 3 个 systemd 服务(backend / celery-worker / celery-beat)并 enable 开机自启 →
|
||||
启动前端。更多细节见下文「生产环境部署建议」。
|
||||
|
||||
#### 方式二:手动启动
|
||||
|
||||
> ⚠️ 仅供开发调试用。生产环境推荐使用方式一(start.sh),可自动生成 systemd 服务、开机自启、崩溃自动重启。
|
||||
|
||||
#### 安装依赖环境
|
||||
```bash
|
||||
cd /root/ipam/backend
|
||||
# 以下 <项目目录> 均指你实际的部署路径,例如 /opt/ipam
|
||||
cd <项目目录>/backend
|
||||
source venv/bin/activate
|
||||
pip install "pysnmp==4.4.12" "pyasn1<0.5.0" "pysmi<0.4.0"
|
||||
apt install -y celery
|
||||
```
|
||||
|
||||
##### 1️⃣ 启动后端 API 服务
|
||||
```bash
|
||||
cd /root/ipam/backend
|
||||
cd <项目目录>/backend
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8008
|
||||
```
|
||||
|
||||
##### 2️⃣ 启动 Celery Worker(执行扫描任务)
|
||||
```bash
|
||||
cd /root/ipam/backend
|
||||
cd <项目目录>/backend
|
||||
source venv/bin/activate
|
||||
celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
|
||||
```
|
||||
|
||||
##### 3️⃣ 启动 Celery Beat(定时任务调度器,自动扫描必须启动)
|
||||
```bash
|
||||
cd /root/ipam/backend
|
||||
cd <项目目录>/backend
|
||||
source venv/bin/activate
|
||||
celery -A app.tasks.celery_app beat --loglevel=info
|
||||
```
|
||||
|
||||
##### 4️⃣ 启动前端
|
||||
```bash
|
||||
cd /root/ipam/frontend
|
||||
cd <项目目录>/frontend
|
||||
npm install
|
||||
npm run dev -- --host 0.0.0.0 --port 3000
|
||||
```
|
||||
@@ -113,7 +122,7 @@ npm run dev -- --host 0.0.0.0 --port 3000
|
||||
|
||||
### 如何自定义扫描频率
|
||||
|
||||
编辑配置文件:`/root/ipam/backend/app/tasks/celery_app.py`
|
||||
编辑配置文件:`<项目目录>/backend/app/tasks/celery_app.py`
|
||||
|
||||
```python
|
||||
# 定时任务配置 (第 36-55 行)
|
||||
@@ -169,7 +178,7 @@ celery_app.conf.beat_schedule = {
|
||||
|
||||
#### 查看 Celery 活跃任务
|
||||
```bash
|
||||
cd /root/ipam/backend
|
||||
cd <项目目录>/backend
|
||||
source venv/bin/activate
|
||||
celery -A app.tasks.celery_app inspect active
|
||||
```
|
||||
@@ -188,83 +197,109 @@ celery -A app.tasks.celery_app inspect stats
|
||||
|
||||
## 🛠️ 生产环境部署建议
|
||||
|
||||
### 使用 Systemd 管理服务(开机自启)
|
||||
### 推荐方式:使用 start.sh(自动化,目录自适应)
|
||||
|
||||
#### 1. 创建 IPAM API 服务
|
||||
```ini
|
||||
# /etc/systemd/system/ipam-api.service
|
||||
[Unit]
|
||||
Description=IPAM API Service
|
||||
After=network.target mysql.service redis.service
|
||||
`start.sh` 已内置完整的生产级部署逻辑,**无需手动创建任何 systemd 单元**:
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/ipam/backend
|
||||
Environment="PATH=/root/ipam/backend/venv/bin"
|
||||
ExecStart=/root/ipam/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8008
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
- **目录自适应**:不硬编码路径,自动以脚本所在目录为项目根。无论部署在
|
||||
`/opt/ipam`、`/usr/local/ipam`、`/root/ipam` 还是其它任意目录都能工作。
|
||||
- **systemd 自动生成**:运行 `start.sh` 时自动生成并 `systemctl enable` 如下
|
||||
3 个服务,实现**开机自启** + **崩溃自动重启**(`Restart=always`):
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
| 服务名 | 内容 | 说明 |
|
||||
|--------|------|------|
|
||||
| `ipam-backend` | uvicorn :8008 | 后端 API |
|
||||
| `ipam-celery-worker` | celery worker | 执行扫描/轮询任务 |
|
||||
| `ipam-celery-beat` | celery beat | 定时任务调度器(每60秒触发 SNMP 设备自动轮询) |
|
||||
|
||||
#### 2. 创建 Celery Worker 服务
|
||||
```ini
|
||||
# /etc/systemd/system/ipam-celery-worker.service
|
||||
[Unit]
|
||||
Description=IPAM Celery Worker
|
||||
After=network.target redis.service
|
||||
日志写入 `/var/log/ipam/*.log`。
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/ipam/backend
|
||||
Environment="PATH=/root/ipam/backend/venv/bin"
|
||||
ExecStart=/root/ipam/backend/venv/bin/celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
#### 首次部署(全新机器)
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
#### 3. 创建 Celery Beat 服务
|
||||
```ini
|
||||
# /etc/systemd/system/ipam-celery-beat.service
|
||||
[Unit]
|
||||
Description=IPAM Celery Beat Scheduler
|
||||
After=network.target redis.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/root/ipam/backend
|
||||
Environment="PATH=/root/ipam/backend/venv/bin"
|
||||
ExecStart=/root/ipam/backend/venv/bin/celery -A app.tasks.celery_app beat --loglevel=info
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
#### 4. 启用并启动服务
|
||||
```bash
|
||||
# 重新加载配置
|
||||
systemctl daemon-reload
|
||||
# 1. 前置:Python3 / Node.js / Docker 需已安装
|
||||
# 2. git clone 代码(放到你想要的任何目录)
|
||||
git clone https://git.cnbugs.com/AI-Agent/ipam.git /opt/ipam
|
||||
cd /opt/ipam
|
||||
|
||||
# 启用开机自启
|
||||
systemctl enable ipam-api ipam-celery-worker ipam-celery-beat
|
||||
|
||||
# 启动服务
|
||||
systemctl start ipam-api ipam-celery-worker ipam-celery-beat
|
||||
|
||||
# 查看服务状态
|
||||
systemctl status ipam-api ipam-celery-worker ipam-celery-beat
|
||||
# 3. 一键启动(需 root 权限,自动装依赖 + 起 MySQL/Redis 容器 + 生成 systemd + 启前端)
|
||||
bash start.sh
|
||||
```
|
||||
|
||||
启动成功后即可访问:
|
||||
- 管理界面 `http://服务器IP:3000`
|
||||
- API 文档 `http://服务器IP:8008/docs`
|
||||
|
||||
> 💡 首次运行 `start.sh` 会自动创建 Python venv、安装前端依赖(含 vite)、
|
||||
> 启动 MySQL/Redis Docker 容器、生成 3 个 systemd 服务并 enable 开机自启,
|
||||
> 全程无需人工干预。若部署目录想换到别处,把项目整个复制过去再跑一次
|
||||
> `bash start.sh` 即可自动重写 systemd 单元。
|
||||
|
||||
#### 服务管理命令
|
||||
|
||||
```bash
|
||||
# 启动 / 停止 / 重启
|
||||
bash start.sh
|
||||
bash stop.sh
|
||||
|
||||
# 查看状态(开机自启 + 运行中)
|
||||
systemctl status ipam-backend ipam-celery-worker ipam-celery-beat
|
||||
|
||||
# 查看日志
|
||||
tail -f /var/log/ipam/ipam-backend.log
|
||||
tail -f /var/log/ipam/ipam-celery-worker.log
|
||||
tail -f /var/log/ipam/ipam-celery-beat.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 生产服务器更新流程(git 部署时)
|
||||
|
||||
生产代码是通过 `git clone` 放上去的,日常更新用 `git pull` 拉取远端最新代码,
|
||||
再重启服务即可。**推荐更新流程**:
|
||||
|
||||
```bash
|
||||
cd /你的部署目录 # 例如 /opt/ipam,跟随你实际的部署路径
|
||||
|
||||
# 1.(可选)备份当前脚本,防止意外
|
||||
cp start.sh /tmp/start.sh.bak
|
||||
|
||||
# 2. 拉取远端最新代码(含全部修复:单IP扫描、SNMP自动轮询、告警检测、部署脚本)
|
||||
git pull origin master
|
||||
|
||||
# 3. 重启服务让新代码生效(需 root)
|
||||
# - 后端 / Celery Worker / Celery Beat 是 systemd 管理,重启它们即可
|
||||
systemctl restart ipam-backend ipam-celery-worker ipam-celery-beat
|
||||
|
||||
# - 前端 vite dev server 是 nohup 方式,需手动重启:
|
||||
pkill -f "vite" ; sleep 2
|
||||
cd frontend && nohup npm run dev -- --host 0.0.0.0 --port 3000 > /tmp/ipam-frontend.log 2>&1 &
|
||||
|
||||
# 4. 全量一键启动(更省心:自动检测目录重新生成 systemd + 拉起全部服务)
|
||||
# bash start.sh
|
||||
|
||||
# 5. 验证服务全部正常
|
||||
systemctl is-active ipam-backend ipam-celery-worker ipam-celery-beat # 应输出 3 个 active
|
||||
curl -s http://localhost:8008/health # 后端健康
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 # 前端 200
|
||||
```
|
||||
|
||||
**更新要点:**
|
||||
|
||||
1. **后端 / Celery 是 systemd 管理**(ipam-backend / ipam-celery-worker /
|
||||
ipam-celery-beat),更新代码后只需 `systemctl restart` 这三个服务,
|
||||
它们会保持开机自启和崩溃自动重启。
|
||||
2. **前端是 nohup 方式**(vite dev server,开发服务不纳入 systemd),需要
|
||||
`pkill -f vite` 后重新 `npm run dev` 启动。
|
||||
3. **依赖变更时**:若 `git pull` 拉取后要求新 Python 包,执行
|
||||
`cd backend && venv/bin/pip install -r requirements.txt`;
|
||||
前端依赖变化则 `cd frontend && npm install --include=dev`。
|
||||
4. **若你切换了部署目录**(从 /opt 挪到 /usr/local 等),直接在新位置跑一次
|
||||
`bash start.sh`,它会自动按新目录重新生成 systemd 单元,无需手动改任何配置。
|
||||
5. **首次部署到新机器**建议直接跑 `bash start.sh`(一键全自动),日常增量更新
|
||||
用第 2~4 步的重启方式即可,两者效果一致。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🌐 访问地址
|
||||
@@ -358,7 +393,7 @@ A: 1. 增加并发数 2. 关闭 DNS 解析(较慢)3. 分网段分时段扫
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
/root/ipam/
|
||||
<项目目录>/ # 例如 /opt/ipam、/usr/local/ipam,任意目录均可
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── api/v1/ # API 路由
|
||||
|
||||
@@ -17,3 +17,16 @@ SECRET_KEY=your-super-secret-key-here-change-in-production
|
||||
PING_TIMEOUT=2
|
||||
PING_RETRIES=2
|
||||
SCAN_CONCURRENCY=50
|
||||
|
||||
# 飞书机器人告警通知配置
|
||||
# 1. 在飞书群添加"自定义机器人",复制 Webhook 地址填入 FEISHU_WEBHOOK_URL
|
||||
# 2. 若机器人开启了"签名校验",把密钥填 FEISHU_SECRET;未开启留空
|
||||
# 3. FEISHU_ENABLED=true 开启总开关
|
||||
# 4. FEISHU_MIN_SEVERITY=warning 仅推送 >= 该级别的告警(info/warning/error/critical)
|
||||
# 5. FEISHU_ALERT_TYPES 可选,逗号分隔的告警类型白名单,留空=全部推送
|
||||
FEISHU_ENABLED=false
|
||||
FEISHU_WEBHOOK_URL=
|
||||
FEISHU_SECRET=
|
||||
FEISHU_MIN_SEVERITY=warning
|
||||
FEISHU_ALERT_TYPES=
|
||||
|
||||
|
||||
@@ -181,3 +181,91 @@ def detect_offline_devices(db: Session = Depends(get_db)):
|
||||
"count": len(offline_devices),
|
||||
"devices": offline_devices
|
||||
}
|
||||
|
||||
|
||||
# ========== 飞书告警通知 ==========
|
||||
|
||||
@router.get("/notify/feishu/config", summary="获取飞书通知配置")
|
||||
def get_feishu_config(db: Session = Depends(get_db)):
|
||||
"""读取飞书通知配置(Web 界面显示用,不返回 secret 原文只返回是否已设置)"""
|
||||
from app.models.notification import NotificationConfig
|
||||
row = NotificationConfig.get_singleton(db)
|
||||
return {
|
||||
"feishu_enabled": bool(row.feishu_enabled),
|
||||
"feishu_webhook_url": row.feishu_webhook_url or "",
|
||||
"feishu_secret_set": bool(row.feishu_secret),
|
||||
"feishu_min_severity": row.feishu_min_severity or "warning",
|
||||
"feishu_alert_types": row.feishu_alert_types or "",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/notify/feishu/config", summary="保存飞书通知配置")
|
||||
def update_feishu_config(
|
||||
feishu_enabled: bool,
|
||||
feishu_webhook_url: str = "",
|
||||
feishu_secret: str = "",
|
||||
feishu_min_severity: str = "warning",
|
||||
feishu_alert_types: str = "",
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""保存飞书通知配置到数据库并热更新(无需重启后端)"""
|
||||
from app.models.notification import NotificationConfig
|
||||
row = NotificationConfig.get_singleton(db)
|
||||
row.feishu_enabled = bool(feishu_enabled)
|
||||
row.feishu_webhook_url = (feishu_webhook_url or "").strip()
|
||||
# 仅当传入新 secret 时才覆盖(避免 UI 回显时清空已有 secret)
|
||||
if feishu_secret:
|
||||
row.feishu_secret = feishu_secret.strip()
|
||||
row.feishu_min_severity = feishu_min_severity or "warning"
|
||||
row.feishu_alert_types = (feishu_alert_types or "").strip()
|
||||
db.commit()
|
||||
|
||||
# 热更新运行时的飞书服务
|
||||
from app.services.feishu_service import reload_feishu_service
|
||||
svc = reload_feishu_service(db)
|
||||
|
||||
return {
|
||||
"message": "飞书通知配置已保存并生效",
|
||||
"success": True,
|
||||
"configured": svc.is_configured(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/notify/feishu/status", summary="获取飞书通知运行状态")
|
||||
def get_feishu_status(db: Session = Depends(get_db)):
|
||||
"""查看飞书通知运行状态"""
|
||||
from app.services.feishu_service import get_feishu_service
|
||||
svc = get_feishu_service(db)
|
||||
return {
|
||||
"enabled": svc.enabled,
|
||||
"configured": bool(svc.webhook_url),
|
||||
"webhook_set": bool(svc.webhook_url),
|
||||
"secret_set": bool(svc.secret),
|
||||
"min_severity": svc.min_severity,
|
||||
"alert_types": svc.alert_types,
|
||||
"cani_push": svc.is_configured(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/notify/feishu/test", summary="发送飞书测试告警")
|
||||
def test_feishu_notification(db: Session = Depends(get_db)):
|
||||
"""发送一条测试告警到飞书,验证 webhook 配置是否正确"""
|
||||
from app.services.feishu_service import get_feishu_service
|
||||
svc = get_feishu_service(db)
|
||||
if not svc.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="飞书通知未启用或未配置 webhook。请在告警中心「飞书通知设置」中配置并开启"
|
||||
)
|
||||
ok = svc.send_alert({
|
||||
"alert_type": "new_device_detected",
|
||||
"severity": "warning",
|
||||
"title": "飞书通知测试",
|
||||
"message": "这是一条测试告警,用于验证飞书机器人 webhook 配置是否正常。",
|
||||
"ip_address_str": "172.16.0.1",
|
||||
"mac_address": "00:11:22:33:44:55",
|
||||
})
|
||||
if ok:
|
||||
return {"message": "飞书测试通知发送成功", "success": True}
|
||||
raise HTTPException(status_code=500, detail="飞书测试通知发送失败,请检查 webhook 地址和日志")
|
||||
|
||||
|
||||
@@ -113,7 +113,8 @@ def comprehensive_scan_ip(
|
||||
EnhancedScanService.update_ip_from_scan_result(db, result)
|
||||
|
||||
# 刷新所属网段统计字段(used_ips/reserved_ips),保证网段列表利用率能即时反映
|
||||
db_ip = db.query(IPAddress).filter(IPAddress.ip_address == ip_address).first()
|
||||
from app.models.network import IPAddress as IPAddressModel
|
||||
db_ip = db.query(IPAddressModel).filter(IPAddressModel.ip_address == ip_address).first()
|
||||
if db_ip:
|
||||
NetworkService.get_stats(db, db_ip.network_id)
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@ class Settings(BaseSettings):
|
||||
PING_RETRIES: int = 2
|
||||
SCAN_CONCURRENCY: int = 50
|
||||
|
||||
# 飞书机器人告警通知配置
|
||||
FEISHU_ENABLED: bool = False # 总开关
|
||||
FEISHU_WEBHOOK_URL: str = "" # 飞书自定义机器人 webhook 地址
|
||||
FEISHU_SECRET: str = "" # 可选:机器人签名校验密钥(开启签名校验时必填)
|
||||
FEISHU_MIN_SEVERITY: str = "warning" # 最低告警级别:info/warning/error/critical,仅推送 >= 该级别
|
||||
FEISHU_ALERT_TYPES: str = "" # 可选:逗号分隔的告警类型白名单,空=全部推送
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.models.snmp import SNMPCredential, NetworkDevice, ARPEntry, MACAddressT
|
||||
from app.models.alert import Alert, AlertRule, WhitelistedMAC
|
||||
from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.notification import NotificationConfig
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import json
|
||||
@@ -11,6 +12,12 @@ from app.models.alert import (
|
||||
from app.models.network import Network, IPAddress
|
||||
from app.models.snmp import NetworkDevice, ARPEntry
|
||||
|
||||
# 飞书告警通知(延迟导入避免循环依赖)
|
||||
def _get_feishu_service(db=None):
|
||||
from app.services.feishu_service import get_feishu_service
|
||||
return get_feishu_service(db)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -69,6 +76,22 @@ class AlertService:
|
||||
db.refresh(alert)
|
||||
|
||||
logger.info(f"创建告警: {alert_type} - {title}")
|
||||
|
||||
# 飞书告警通知(仅新创建的告警推送;existing 去重返回的不会走到这里,避免刷屏)
|
||||
try:
|
||||
_get_feishu_service(db).send_alert({
|
||||
"alert_type": alert_type.value if hasattr(alert_type, "value") else str(alert_type),
|
||||
"severity": severity.value if hasattr(severity, "value") else str(severity),
|
||||
"title": title,
|
||||
"message": message,
|
||||
"ip_address_str": ip_address_str,
|
||||
"mac_address": mac_address,
|
||||
"conflicting_mac": conflicting_mac,
|
||||
"usage_percent": usage_percent,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"飞书告警通知失败(不影响告警创建): {e}")
|
||||
|
||||
return alert
|
||||
|
||||
@staticmethod
|
||||
@@ -80,7 +103,7 @@ class AlertService:
|
||||
subquery = db.query(
|
||||
ARPEntry.ip_address
|
||||
).group_by(ARPEntry.ip_address).having(
|
||||
db.func.count(db.func.distinct(ARPEntry.mac_address)) > 1
|
||||
func.count(func.distinct(ARPEntry.mac_address)) > 1
|
||||
).subquery()
|
||||
|
||||
conflict_entries = db.query(ARPEntry).filter(
|
||||
|
||||
@@ -429,17 +429,7 @@ class EnhancedScanService:
|
||||
|
||||
# 如果IP不存在,尝试自动创建
|
||||
if not db_ip:
|
||||
network = db.query(Network).filter(
|
||||
Network.cidr.op('@>')((ip_address + '/32').encode('utf-8'))
|
||||
).first()
|
||||
if not network:
|
||||
for net in db.query(Network).all():
|
||||
try:
|
||||
if ip_address in ipaddress.ip_network(net.cidr, strict=False):
|
||||
network = net
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
network = EnhancedScanService._find_network_for_ip(db, ip_address)
|
||||
if not network:
|
||||
logger.warning(f"无法为扫描到的IP {ip_address} 找到所属网段,跳过创建")
|
||||
return
|
||||
@@ -481,6 +471,31 @@ class EnhancedScanService:
|
||||
|
||||
return db_ip
|
||||
|
||||
@staticmethod
|
||||
def _find_network_for_ip(db: Session, ip_address: str) -> Optional[Network]:
|
||||
"""
|
||||
查找 IP 所属的网段(兼容 MySQL,不使用 PostgreSQL 的 @> 操作符)。
|
||||
优先按网段越大越精确匹配,无法精确匹配时返回包含该 IP 的最小/最合适网段。
|
||||
"""
|
||||
try:
|
||||
target = ipaddress.ip_address(ip_address)
|
||||
except ValueError:
|
||||
return None
|
||||
best = None
|
||||
best_prefix = -1
|
||||
for net in db.query(Network).all():
|
||||
try:
|
||||
cidr = str(net.cidr)
|
||||
net_obj = ipaddress.ip_network(cidr, strict=False)
|
||||
if target in net_obj:
|
||||
# 取前缀最长的(网段最小、最精确)
|
||||
if net_obj.prefixlen > best_prefix:
|
||||
best = net
|
||||
best_prefix = net_obj.prefixlen
|
||||
except Exception:
|
||||
continue
|
||||
return best
|
||||
|
||||
@staticmethod
|
||||
def bulk_update_ips_from_scan(db: Session, scan_results: List[Dict[str, Any]]):
|
||||
"""批量更新IP信息"""
|
||||
|
||||
@@ -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)
|
||||
@@ -32,6 +32,7 @@ scan_single_ip_task = _tasks['scan_single_ip_task']
|
||||
full_network_scan = _tasks['full_network_scan']
|
||||
update_all_statistics = _tasks['update_all_statistics']
|
||||
quick_status_update = _tasks['quick_status_update']
|
||||
poll_snmp_devices = _tasks['poll_snmp_devices']
|
||||
|
||||
# 定时任务配置
|
||||
celery_app.conf.beat_schedule = {
|
||||
@@ -52,4 +53,9 @@ celery_app.conf.beat_schedule = {
|
||||
'task': 'app.tasks.scan_tasks.update_all_statistics',
|
||||
'schedule': 900.0, # 15分钟
|
||||
},
|
||||
# 每60秒检查一次 SNMP 设备轮询(各设备按自己的 poll_interval 判断是否到期)
|
||||
'poll-snmp-devices-every-60s': {
|
||||
'task': 'app.tasks.scan_tasks.poll_snmp_devices',
|
||||
'schedule': 60.0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -212,11 +212,75 @@ def register_tasks(celery_app):
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def poll_snmp_devices(self):
|
||||
"""
|
||||
定时任务:自动轮询所有活跃且配置了 SNMP 凭据的网络设备。
|
||||
|
||||
每个设备按自己的 arp/mac/interface_poll_interval(秒)判断是否到达
|
||||
轮询间隔,到期才执行 SNMPService.poll_device(采集 ARP 表、MAC 地址
|
||||
表、接口信息)。由 Celery Beat 每 60 秒触发一次检查。
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.snmp import NetworkDevice
|
||||
from app.services.snmp_service import SNMPService
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
devices = db.query(NetworkDevice).filter(
|
||||
NetworkDevice.is_active == True,
|
||||
NetworkDevice.snmp_credential_id.isnot(None),
|
||||
).all()
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
def _as_naive(dt):
|
||||
"""把 aware/naive datetime 统一转 naive(本地比较用)"""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is not None:
|
||||
return dt.replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
polled, skipped = 0, 0
|
||||
for dev in devices:
|
||||
# 取三种采集间隔的最小值作为该设备的轮询周期
|
||||
intervals = [
|
||||
dev.arp_poll_interval,
|
||||
dev.mac_poll_interval,
|
||||
dev.interface_poll_interval,
|
||||
]
|
||||
interval = min([i for i in intervals if i], default=300)
|
||||
|
||||
last = _as_naive(dev.last_polled_at)
|
||||
if last is not None:
|
||||
elapsed = (now - last).total_seconds()
|
||||
if elapsed < interval:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# poll_device 是 async 函数,用 asyncio.run 在同步任务中执行
|
||||
asyncio.run(SNMPService.poll_device(db, dev.id))
|
||||
polled += 1
|
||||
logger.info(f"SNMP 自动轮询完成: {dev.name} ({dev.ip_address})")
|
||||
except Exception as e:
|
||||
logger.error(f"SNMP 自动轮询失败 {dev.name} ({dev.ip_address}): {e}", exc_info=True)
|
||||
|
||||
logger.info(f"SNMP 自动轮询检查完成: 设备总数 {len(devices)}, 本轮轮询 {polled}, 未到期跳过 {skipped}")
|
||||
return {'status': 'completed', 'total': len(devices), 'polled': polled, 'skipped': skipped}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# 返回任务函数供外部使用
|
||||
return {
|
||||
'scan_network_task': scan_network_task,
|
||||
'scan_single_ip_task': scan_single_ip_task,
|
||||
'full_network_scan': full_network_scan,
|
||||
'update_all_statistics': update_all_statistics,
|
||||
'quick_status_update': quick_status_update
|
||||
'quick_status_update': quick_status_update,
|
||||
'poll_snmp_devices': poll_snmp_devices,
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,19 @@ export const alertApi = {
|
||||
},
|
||||
removeFromWhitelist(id) {
|
||||
return api.delete(`/alerts/whitelist/macs/${id}`)
|
||||
},
|
||||
// 飞书通知配置
|
||||
getFeishuConfig() {
|
||||
return api.get('/alerts/notify/feishu/config')
|
||||
},
|
||||
saveFeishuConfig(data) {
|
||||
return api.put('/alerts/notify/feishu/config', {}, { params: data })
|
||||
},
|
||||
getFeishuStatus() {
|
||||
return api.get('/alerts/notify/feishu/status')
|
||||
},
|
||||
testFeishu() {
|
||||
return api.post('/alerts/notify/feishu/test')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
<el-icon><Check /></el-icon>
|
||||
全部确认
|
||||
</el-button>
|
||||
<el-button @click="openFeishuDialog">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
飞书通知设置
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</template>
|
||||
@@ -231,6 +235,47 @@
|
||||
<el-button type="primary" @click="addToWhitelist" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 飞书通知设置弹窗 -->
|
||||
<el-dialog v-model="feishuDialogVisible" title="飞书通知设置" width="560px">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 16px"
|
||||
title="在飞书群添加「自定义机器人」,将 Webhook 地址填入下方即可。开启后,检测到新告警会自动推送到飞书群。"
|
||||
show-icon />
|
||||
<el-form label-width="110px" label-position="left">
|
||||
<el-form-item label="启用通知">
|
||||
<el-switch v-model="feishuForm.feishu_enabled" active-text="开启" inactive-text="关闭" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Webhook 地址">
|
||||
<el-input v-model="feishuForm.feishu_webhook_url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxxx" />
|
||||
</el-form-item>
|
||||
<el-form-item label="签名密钥">
|
||||
<el-input v-model="feishuForm.feishu_secret" show-password
|
||||
:placeholder="feishuForm.feishu_secret_set ? '已设置(留空则不修改)' : '机器人开启签名校验时填写'" />
|
||||
<div class="form-tip">机器人开启「签名校验」时必填;未开启留空即可</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最低级别">
|
||||
<el-select v-model="feishuForm.feishu_min_severity" style="width: 200px">
|
||||
<el-option label="全部 (info起)" value="info" />
|
||||
<el-option label="警告及以上" value="warning" />
|
||||
<el-option label="错误及以上" value="error" />
|
||||
<el-option label="仅严重" value="critical" />
|
||||
</el-select>
|
||||
<div class="form-tip">仅推送大于等于所选级别的告警</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型白名单">
|
||||
<el-input v-model="feishuForm.feishu_alert_types" placeholder="留空=全部推送;如 ip_conflict,device_offline" />
|
||||
<div class="form-tip">逗号分隔告警类型,留空则推送全部类型</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="color: #909399; font-size: 12px; margin-top: 8px;">
|
||||
配置保存后立即生效,无需重启服务。
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="feishuDialogVisible = false">关闭</el-button>
|
||||
<el-button @click="testFeishu" :loading="testing">发送测试</el-button>
|
||||
<el-button type="primary" @click="saveFeishuConfig" :loading="savingFeishu">保存配置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -463,6 +508,67 @@ const getAlertTypeText = (type) => {
|
||||
|
||||
// 旧 formatDate 已由 @/utils/datetime 统一替代
|
||||
|
||||
// ---- 飞书通知配置 ----
|
||||
const feishuDialogVisible = ref(false)
|
||||
const savingFeishu = ref(false)
|
||||
const testing = ref(false)
|
||||
const feishuForm = reactive({
|
||||
feishu_enabled: false,
|
||||
feishu_webhook_url: '',
|
||||
feishu_secret: '',
|
||||
feishu_secret_set: false,
|
||||
feishu_min_severity: 'warning',
|
||||
feishu_alert_types: ''
|
||||
})
|
||||
|
||||
const openFeishuDialog = async () => {
|
||||
feishuDialogVisible.value = true
|
||||
try {
|
||||
const cfg = await alertApi.getFeishuConfig()
|
||||
feishuForm.feishu_enabled = !!cfg.feishu_enabled
|
||||
feishuForm.feishu_webhook_url = cfg.feishu_webhook_url || ''
|
||||
feishuForm.feishu_secret = ''
|
||||
feishuForm.feishu_secret_set = !!cfg.feishu_secret_set
|
||||
feishuForm.feishu_min_severity = cfg.feishu_min_severity || 'warning'
|
||||
feishuForm.feishu_alert_types = cfg.feishu_alert_types || ''
|
||||
} catch (error) {
|
||||
ElMessage.error('加载飞书配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveFeishuConfig = async () => {
|
||||
savingFeishu.value = true
|
||||
try {
|
||||
const params = {
|
||||
feishu_enabled: feishuForm.feishu_enabled,
|
||||
feishu_webhook_url: feishuForm.feishu_webhook_url,
|
||||
feishu_secret: feishuForm.feishu_secret,
|
||||
feishu_min_severity: feishuForm.feishu_min_severity,
|
||||
feishu_alert_types: feishuForm.feishu_alert_types
|
||||
}
|
||||
const res = await alertApi.saveFeishuConfig(params)
|
||||
ElMessage.success(res.message || '飞书配置已保存')
|
||||
feishuForm.feishu_secret = ''
|
||||
feishuForm.feishu_secret_set = true
|
||||
} catch (error) {
|
||||
ElMessage.error('保存飞书配置失败')
|
||||
} finally {
|
||||
savingFeishu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testFeishu = async () => {
|
||||
testing.value = true
|
||||
try {
|
||||
const res = await alertApi.testFeishu()
|
||||
ElMessage.success(res.message || '测试通知已发送')
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.response?.data?.detail || '发送测试失败,请检查配置')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadAlerts()
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
#!/bin/bash
|
||||
# IPAM 管理系统一键启动脚本
|
||||
#
|
||||
# 特点:
|
||||
# - 目录自适应:不硬编码 /root/ipam,自动跟随本脚本所在目录。
|
||||
# 无论部署在 /opt、/usr/local、/root 还是其它任意目录都能工作。
|
||||
# - systemd 管理:后端、Celery Worker、Celery Beat 都生成 systemd 单元
|
||||
# (ipam-backend / ipam-celery-worker / ipam-celery-beat),
|
||||
# 运行本脚本时自动生成并 enable,保证开机自启、崩溃自动重启。
|
||||
# - 前端 vite dev server 仍用 nohup 启动(开发服务,不纳入 systemd)。
|
||||
|
||||
echo "=========================================="
|
||||
echo " IPAM 管理系统启动脚本"
|
||||
@@ -12,8 +20,10 @@ GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 项目根目录
|
||||
BASE_DIR="/root/ipam"
|
||||
# ============ 目录自适应(核心改动)============
|
||||
# 无论脚本放哪里,都以其所在目录为项目根目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BASE_DIR="$SCRIPT_DIR"
|
||||
BACKEND_DIR="$BASE_DIR/backend"
|
||||
FRONTEND_DIR="$BASE_DIR/frontend"
|
||||
|
||||
@@ -21,6 +31,17 @@ FRONTEND_DIR="$BASE_DIR/frontend"
|
||||
API_PORT=8008
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# systemd 服务名
|
||||
SYSTEMD_BACKEND="ipam-backend"
|
||||
SYSTEMD_CELERY_WORKER="ipam-celery-worker"
|
||||
SYSTEMD_CELERY_BEAT="ipam-celery-beat"
|
||||
|
||||
# 日志目录
|
||||
LOG_DIR="/var/log/ipam"
|
||||
mkdir -p "$LOG_DIR" 2>/dev/null
|
||||
|
||||
echo -e "${GREEN}📁 项目目录: $BASE_DIR${NC}"
|
||||
|
||||
# 检查 Docker 容器状态
|
||||
check_docker_containers() {
|
||||
echo "🔍 检查 Docker 容器..."
|
||||
@@ -31,10 +52,10 @@ check_docker_containers() {
|
||||
docker start ipam-mysql 2>/dev/null || {
|
||||
echo -e "${YELLOW}创建 MySQL 容器...${NC}"
|
||||
docker run -d --name ipam-mysql -p 3308:3306 \
|
||||
-e MYSQL_ROOT_PASSWORD=*** \
|
||||
-e MYSQL_ROOT_PASSWORD=ipam2024 \
|
||||
-e MYSQL_DATABASE=ipam \
|
||||
-e MYSQL_USER=ipam \
|
||||
-e MYSQL_PASSWORD=*** \
|
||||
-e MYSQL_PASSWORD=ipam2024 \
|
||||
mysql:8.0 --default-authentication-plugin=mysql_native_password
|
||||
}
|
||||
fi
|
||||
@@ -63,7 +84,90 @@ kill_port() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 启动后端 API
|
||||
# ============ systemd 单元动态生成 ============
|
||||
# 生成 3 个服务单元(backend / celery-worker / celery-beat)
|
||||
# 路径全部跟随 BASE_DIR,保证部署目录变更后重新运行本脚本即可重写
|
||||
setup_systemd_services() {
|
||||
echo "⚙️ 生成 systemd 服务单元..."
|
||||
|
||||
# --- 后端 ---
|
||||
cat > /etc/systemd/system/$SYSTEMD_BACKEND.service <<EOF
|
||||
[Unit]
|
||||
Description=IPAM Backend API (uvicorn)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$BACKEND_DIR
|
||||
Environment=PYTHONPATH=$BACKEND_DIR
|
||||
ExecStart=$BACKEND_DIR/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port $API_PORT
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
StandardOutput=append:$LOG_DIR/ipam-backend.log
|
||||
StandardError=append:$LOG_DIR/ipam-backend.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# --- Celery Worker ---
|
||||
cat > /etc/systemd/system/$SYSTEMD_CELERY_WORKER.service <<EOF
|
||||
[Unit]
|
||||
Description=IPAM Celery Worker
|
||||
After=$SYSTEMD_BACKEND.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$BACKEND_DIR
|
||||
Environment=PYTHONPATH=$BACKEND_DIR
|
||||
ExecStart=$BACKEND_DIR/venv/bin/celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:$LOG_DIR/ipam-celery-worker.log
|
||||
StandardError=append:$LOG_DIR/ipam-celery-worker.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# --- Celery Beat ---
|
||||
cat > /etc/systemd/system/$SYSTEMD_CELERY_BEAT.service <<EOF
|
||||
[Unit]
|
||||
Description=IPAM Celery Beat Scheduler
|
||||
After=$SYSTEMD_CELERY_WORKER.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$BACKEND_DIR
|
||||
Environment=PYTHONPATH=$BACKEND_DIR
|
||||
ExecStart=$BACKEND_DIR/venv/bin/celery -A app.tasks.celery_app beat --loglevel=info
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:$LOG_DIR/ipam-celery-beat.log
|
||||
StandardError=append:$LOG_DIR/ipam-celery-beat.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
# enable 开机自启
|
||||
systemctl enable $SYSTEMD_BACKEND >/dev/null 2>&1
|
||||
systemctl enable $SYSTEMD_CELERY_WORKER >/dev/null 2>&1
|
||||
systemctl enable $SYSTEMD_CELERY_BEAT >/dev/null 2>&1
|
||||
|
||||
echo -e "${GREEN}✅ systemd 单元已生成并启用开机自启:${NC}"
|
||||
echo " - $SYSTEMD_BACKEND.service"
|
||||
echo " - $SYSTEMD_CELERY_WORKER.service"
|
||||
echo " - $SYSTEMD_CELERY_BEAT.service"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 启动后端(systemd)
|
||||
start_backend() {
|
||||
echo "🚀 启动后端 API 服务..."
|
||||
|
||||
@@ -74,74 +178,55 @@ start_backend() {
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
# 激活虚拟环境并启动
|
||||
cd $BACKEND_DIR
|
||||
source venv/bin/activate
|
||||
|
||||
# 检查端口并清理
|
||||
# 检查并清理占用端口的旧进程
|
||||
kill_port $API_PORT
|
||||
|
||||
# 后台启动 uvicorn
|
||||
nohup uvicorn app.main:app --host 0.0.0.0 --port $API_PORT > /tmp/ipam-backend.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
# 通过 systemd 启动
|
||||
systemctl restart $SYSTEMD_BACKEND
|
||||
sleep 4
|
||||
|
||||
# 等待服务启动
|
||||
sleep 5
|
||||
|
||||
# 检查是否启动成功
|
||||
if curl -s http://localhost:$API_PORT/health > /dev/null; then
|
||||
echo -e "${GREEN}✅ 后端 API 启动成功 (端口 $API_PORT)${NC}"
|
||||
echo " PID: $BACKEND_PID"
|
||||
echo " 日志: /tmp/ipam-backend.log"
|
||||
echo -e "${GREEN}✅ 后端 API 启动成功 (端口 $API_PORT, systemd 管理)${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ 后端 API 启动失败${NC}"
|
||||
echo " 查看日志: tail -50 /tmp/ipam-backend.log"
|
||||
echo " 查看日志: journalctl -u $SYSTEMD_BACKEND -n 50 或 tail -50 $LOG_DIR/ipam-backend.log"
|
||||
return 1
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 启动 Celery Worker
|
||||
# 启动 Celery(systemd:worker + beat)
|
||||
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
|
||||
source venv/bin/activate
|
||||
systemctl is-active --quiet $SYSTEMD_CELERY_WORKER && WORKER_ACTIVE="yes" || WORKER_ACTIVE="no"
|
||||
systemctl is-active --quiet $SYSTEMD_CELERY_BEAT && BEAT_ACTIVE="yes" || BEAT_ACTIVE="no"
|
||||
|
||||
# 检查是否已有 celery 进程
|
||||
pkill -f "celery.*app.tasks.celery_app" 2>/dev/null
|
||||
sleep 1
|
||||
|
||||
# 后台启动 celery
|
||||
nohup celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4 > /tmp/ipam-celery.log 2>&1 &
|
||||
CELERY_PID=$!
|
||||
|
||||
sleep 3
|
||||
|
||||
if ps -p $CELERY_PID > /dev/null; then
|
||||
echo -e "${GREEN}✅ Celery Worker 启动成功${NC}"
|
||||
echo " PID: $CELERY_PID"
|
||||
echo " 日志: /tmp/ipam-celery.log"
|
||||
if [ "$WORKER_ACTIVE" = "yes" ] && [ "$BEAT_ACTIVE" = "yes" ]; then
|
||||
echo -e "${GREEN}✅ Celery Worker & Beat 启动成功 (systemd 管理)${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ Celery Worker 启动失败${NC}"
|
||||
echo " 查看日志: tail -50 /tmp/ipam-celery.log"
|
||||
echo -e "${RED}❌ Celery 启动异常 (worker=$WORKER_ACTIVE beat=$BEAT_ACTIVE)${NC}"
|
||||
echo " 查看日志: journalctl -u $SYSTEMD_CELERY_WORKER -n 30 -u $SYSTEMD_CELERY_BEAT -n 30"
|
||||
return 1
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 启动前端
|
||||
# 启动前端(nohup,开发服务)
|
||||
start_frontend() {
|
||||
echo "🚀 启动前端服务..."
|
||||
|
||||
cd $FRONTEND_DIR
|
||||
|
||||
# 检查 node_modules 及关键依赖是否完整
|
||||
if [ ! -d "node_modules" ] || [ ! -f "node_modules/element-plus/es/index.mjs" ]; then
|
||||
echo -e "${YELLOW}安装前端依赖...${NC}"
|
||||
npm install
|
||||
# 检查 node_modules 及关键依赖是否完整(含 vite,全局 npm 可能配置了 omit=dev)
|
||||
if [ ! -d "node_modules" ] || [ ! -f "node_modules/vite/bin/vite.js" ]; then
|
||||
echo -e "${YELLOW}安装前端依赖(含 devDependencies,保证 vite 可用)...${NC}"
|
||||
npm install --include=dev
|
||||
fi
|
||||
|
||||
# 检查 OUI 数据库文件
|
||||
@@ -155,7 +240,6 @@ 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):
|
||||
@@ -185,7 +269,6 @@ if os.path.exists(src):
|
||||
echo " 日志: /tmp/ipam-frontend.log"
|
||||
return 0
|
||||
else
|
||||
# 如果启动失败,尝试清缓存重试一次
|
||||
echo -e "${YELLOW}首次启动失败,尝试清理 Vite 缓存重试...${NC}"
|
||||
kill -9 $FRONTEND_PID 2>/dev/null
|
||||
sleep 1
|
||||
@@ -217,8 +300,9 @@ show_status() {
|
||||
echo "📊 服务状态:"
|
||||
echo " - MySQL: 127.0.0.1:3308 ✅"
|
||||
echo " - Redis: 127.0.0.1:6379 ✅"
|
||||
echo " - 后端 API: http://$(hostname -I | awk '{print $1}'):$API_PORT ✅"
|
||||
echo " - Celery: 后台运行 ✅"
|
||||
echo " - 后端 API: http://$(hostname -I | awk '{print $1}'):$API_PORT ✅ (systemd: $SYSTEMD_BACKEND)"
|
||||
echo " - Celery Worker: systemd: $SYSTEMD_CELERY_WORKER ✅"
|
||||
echo " - Celery Beat: systemd: $SYSTEMD_CELERY_BEAT ✅(每60秒触发SNMP设备自动轮询)"
|
||||
echo " - 前端界面: http://$(hostname -I | awk '{print $1}'):$FRONTEND_PORT ✅"
|
||||
echo ""
|
||||
echo "📖 访问地址:"
|
||||
@@ -226,7 +310,11 @@ show_status() {
|
||||
echo " - API 文档: http://$(hostname -I | awk '{print $1}'):$API_PORT/docs"
|
||||
echo ""
|
||||
echo "🛑 停止服务命令:"
|
||||
echo " pkill -f 'uvicorn|celery|vite'"
|
||||
echo " bash $SCRIPT_DIR/stop.sh"
|
||||
echo " 或 systemctl stop $SYSTEMD_CELERY_BEAT $SYSTEMD_CELERY_WORKER $SYSTEMD_BACKEND"
|
||||
echo ""
|
||||
echo "📈 查看状态:systemctl status $SYSTEMD_BACKEND $SYSTEMD_CELERY_WORKER $SYSTEMD_CELERY_BEAT"
|
||||
echo "📈 查看日志:journalctl -u $SYSTEMD_CELERY_BEAT -f"
|
||||
echo ""
|
||||
}
|
||||
|
||||
@@ -234,7 +322,7 @@ show_status() {
|
||||
main() {
|
||||
# 检查是否是 root 用户
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}请使用 root 权限运行此脚本${NC}"
|
||||
echo -e "${RED}请使用 root 权限运行此脚本(生成 systemd 单元需要)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -258,6 +346,7 @@ main() {
|
||||
|
||||
echo ""
|
||||
check_docker_containers
|
||||
setup_systemd_services
|
||||
start_backend
|
||||
start_celery
|
||||
start_frontend
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#!/bin/bash
|
||||
# IPAM 管理系统停止脚本
|
||||
#
|
||||
# 通过 systemd 停止后端与 Celery(若服务已接入 systemd),
|
||||
# 并停止前端 dev server。
|
||||
|
||||
echo "=========================================="
|
||||
echo " IPAM 管理系统停止脚本"
|
||||
@@ -12,37 +15,52 @@ GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 目录自适应(跟随本脚本所在目录)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BASE_DIR="$SCRIPT_DIR"
|
||||
|
||||
# systemd 服务名(与 start.sh 保持一致)
|
||||
SYSTEMD_BACKEND="ipam-backend"
|
||||
SYSTEMD_CELERY_WORKER="ipam-celery-worker"
|
||||
SYSTEMD_CELERY_BEAT="ipam-celery-beat"
|
||||
|
||||
echo "🛑 正在停止所有服务..."
|
||||
echo ""
|
||||
|
||||
# 停止后端
|
||||
echo "停止后端 API 服务..."
|
||||
pkill -f "uvicorn app.main:app" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ 后端已停止${NC}"
|
||||
# 停止 Celery Beat
|
||||
echo "停止 Celery Beat..."
|
||||
if systemctl stop $SYSTEMD_CELERY_BEAT 2>/dev/null; then
|
||||
echo -e "${GREEN}✅ Celery Beat 已停止 (systemd)${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}ℹ️ 后端未运行${NC}"
|
||||
pkill -f "celery.*app.tasks.celery_app.*beat" 2>/dev/null
|
||||
echo -e "${YELLOW}ℹ️ Celery Beat 已停止 (pkill 兜底)${NC}"
|
||||
fi
|
||||
|
||||
# 停止 Celery
|
||||
# 停止 Celery Worker
|
||||
echo ""
|
||||
echo "停止 Celery Worker..."
|
||||
pkill -f "celery.*app.tasks.celery_app" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ Celery Worker 已停止${NC}"
|
||||
if systemctl stop $SYSTEMD_CELERY_WORKER 2>/dev/null; then
|
||||
echo -e "${GREEN}✅ Celery Worker 已停止 (systemd)${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}ℹ️ Celery Worker 未运行${NC}"
|
||||
pkill -f "celery.*app.tasks.celery_app" 2>/dev/null
|
||||
echo -e "${YELLOW}ℹ️ Celery Worker 已停止 (pkill 兜底)${NC}"
|
||||
fi
|
||||
|
||||
# 停止后端
|
||||
echo ""
|
||||
echo "停止后端 API 服务..."
|
||||
if systemctl stop $SYSTEMD_BACKEND 2>/dev/null; then
|
||||
echo -e "${GREEN}✅ 后端已停止 (systemd)${NC}"
|
||||
else
|
||||
pkill -f "uvicorn app.main:app" 2>/dev/null
|
||||
echo -e "${YELLOW}ℹ️ 后端已停止 (pkill 兜底)${NC}"
|
||||
fi
|
||||
|
||||
# 停止前端
|
||||
echo ""
|
||||
echo "停止前端服务..."
|
||||
pkill -f "vite" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ 前端服务已停止${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}ℹ️ 前端服务未运行${NC}"
|
||||
fi
|
||||
echo -e "${GREEN}✅ 前端服务已停止${NC}"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
@@ -52,3 +70,6 @@ echo ""
|
||||
echo "📋 剩余进程检查:"
|
||||
ps aux | grep -E "(uvicorn|celery|vite)" | grep -v grep | awk '{print $2, $11}'
|
||||
echo ""
|
||||
echo "💡 若希望永久停止开机自启,可执行:"
|
||||
echo " systemctl disable $SYSTEMD_BACKEND $SYSTEMD_CELERY_WORKER $SYSTEMD_CELERY_BEAT"
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user