初始化
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# 代码规范
|
||||
|
||||
## Python 后端规范
|
||||
|
||||
- 遵循 PEP 8 规范
|
||||
- 使用 Black 进行代码格式化
|
||||
- 使用 isort 进行导入排序
|
||||
- 使用类型注解(Type Hints)
|
||||
- 异步函数使用 async/await
|
||||
- 错误处理使用自定义异常类
|
||||
|
||||
**示例**:
|
||||
```python
|
||||
from typing import List, Optional
|
||||
from fastapi import HTTPException
|
||||
|
||||
async def get_devices(
|
||||
skip: int = 0,
|
||||
limit: int = 100
|
||||
) -> List[Device]:
|
||||
"""获取设备列表"""
|
||||
try:
|
||||
devices = await device_service.get_all(skip, limit)
|
||||
return devices
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
```
|
||||
|
||||
## Vue 前端规范
|
||||
|
||||
- 使用 Composition API(setup script)
|
||||
- 组件使用 PascalCase 命名
|
||||
- 使用 TypeScript 类型检查
|
||||
- 遵循 Vue 官方风格指南
|
||||
- 使用 ESLint + Prettier 格式化
|
||||
|
||||
**示例**:
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { Device } from '@/types'
|
||||
|
||||
const devices = ref<Device[]>([])
|
||||
|
||||
const fetchDevices = async () => {
|
||||
// 实现逻辑
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDevices()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Git 提交规范
|
||||
|
||||
使用 Conventional Commits 格式:
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
类型:
|
||||
- feat: 新功能
|
||||
- fix: 修复bug
|
||||
- docs: 文档更新
|
||||
- style: 代码格式
|
||||
- refactor: 重构
|
||||
- test: 测试
|
||||
- chore: 构建/工具
|
||||
|
||||
示例:
|
||||
feat(device): 添加设备导入功能
|
||||
fix(auth): 修复登录token过期问题
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
# 架构规则
|
||||
|
||||
## 后端架构
|
||||
|
||||
### 分层架构
|
||||
- **API 层**:仅处理请求/响应,调用 Service
|
||||
- **Service 层**:业务逻辑,不直接操作数据库
|
||||
- **Model 层**:SQLAlchemy 模型定义
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
app/
|
||||
├── api/v1/ # API 路由
|
||||
├── services/ # 业务逻辑
|
||||
├── models/ # 数据模型
|
||||
├── schemas/ # Pydantic 模式
|
||||
├── core/ # 核心配置
|
||||
├── tasks/ # Celery 任务
|
||||
└── utils/ # 工具函数
|
||||
```
|
||||
|
||||
### 异步任务
|
||||
- 耗时操作使用 Celery
|
||||
- SSH 状态检查使用异步任务
|
||||
- 批量导入使用后台任务
|
||||
|
||||
## 前端架构
|
||||
|
||||
### 组件分类
|
||||
- `components/common/`:通用组件(Layout、Table、Form)
|
||||
- `components/business/`:业务组件(Device、User)
|
||||
- `views/`:页面组件
|
||||
|
||||
### 状态管理
|
||||
- 使用 Pinia stores
|
||||
- 按模块划分:auth、device、user、system
|
||||
|
||||
### API 调用
|
||||
- 统一在 `api/` 目录封装
|
||||
- 使用 Axios 拦截器处理认证和错误
|
||||
@@ -0,0 +1,44 @@
|
||||
# 安全规则
|
||||
|
||||
## 数据安全
|
||||
|
||||
- SSH 密码必须加密存储(使用 Fernet 加密)
|
||||
- 敏感信息通过环境变量配置
|
||||
- 数据库连接使用 SSL
|
||||
- 定期备份数据(90天历史记录)
|
||||
|
||||
**密码加密示例**:
|
||||
```python
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
def encrypt_password(password: str, key: bytes) -> str:
|
||||
f = Fernet(key)
|
||||
return f.encrypt(password.encode()).decode()
|
||||
|
||||
def decrypt_password(encrypted: str, key: bytes) -> str:
|
||||
f = Fernet(key)
|
||||
return f.decrypt(encrypted.encode()).decode()
|
||||
```
|
||||
|
||||
## 应用安全
|
||||
|
||||
- 所有 API 端点需要认证(除登录接口)
|
||||
- 实现 CSRF 保护
|
||||
- 输入验证使用 Pydantic
|
||||
- SQL 注入防护(使用 ORM)
|
||||
- XSS 防护(前端转义)
|
||||
- 实现速率限制
|
||||
|
||||
## 访问控制
|
||||
|
||||
- 基于 RBAC 的权限控制
|
||||
- 数据级权限过滤(按区域/学校)
|
||||
- 操作审计日志记录
|
||||
- 设备信息变更需要审核
|
||||
|
||||
**权限级别**:
|
||||
- 超级管理员:所有权限
|
||||
- 管理员:管理所有设备和用户
|
||||
- 区域管理员:管理指定区域的设备
|
||||
- 学校管理员:管理指定学校的设备
|
||||
- 普通用户:只读权限
|
||||
@@ -0,0 +1,41 @@
|
||||
# 性能规则
|
||||
|
||||
## 数据库优化
|
||||
|
||||
- 为常用查询字段添加索引(mac_address, region, school_name)
|
||||
- 使用连接池(pool_size=20, max_overflow=40)
|
||||
- 避免 N+1 查询问题
|
||||
- 定期清理历史数据(保留90天)
|
||||
- 使用 select_related/joinedload 预加载关联数据
|
||||
|
||||
**索引示例**:
|
||||
```python
|
||||
class ONUDevice(Base):
|
||||
__tablename__ = "onu_devices"
|
||||
|
||||
mac_address = Column(String(17), index=True)
|
||||
region = Column(String(100), index=True)
|
||||
school_name = Column(String(200), index=True)
|
||||
```
|
||||
|
||||
## 缓存策略
|
||||
|
||||
- Redis 缓存热点数据(设备状态)
|
||||
- 缓存过期时间:30分钟
|
||||
- 手动刷新有5分钟冷却限制
|
||||
- 使用缓存键命名规范:`device:status:{device_id}`
|
||||
|
||||
## 异步处理
|
||||
|
||||
- SSH 状态检查使用 Celery 异步任务
|
||||
- 批量导入使用后台任务
|
||||
- 定时任务使用 Celery Beat(每30分钟)
|
||||
- 分批处理大量设备(每批100个)
|
||||
|
||||
## 前端优化
|
||||
|
||||
- 组件懒加载
|
||||
- 图片懒加载
|
||||
- 虚拟滚动(大列表)
|
||||
- 防抖和节流
|
||||
- 资源压缩和 CDN
|
||||
@@ -0,0 +1,48 @@
|
||||
# 开发规则
|
||||
|
||||
## 环境配置
|
||||
|
||||
- 开发环境使用 `.env.development`
|
||||
- 生产环境使用 `.env.production`
|
||||
- 不提交 `.env` 文件到 Git
|
||||
- 提供 `.env.example` 模板
|
||||
|
||||
**必需环境变量**:
|
||||
```bash
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dbname
|
||||
REDIS_URL=redis://host:6379/0
|
||||
CASDOOR_ENDPOINT=https://casdoor.example.com
|
||||
CASDOOR_CLIENT_ID=your_client_id
|
||||
CASDOOR_CLIENT_SECRET=your_client_secret
|
||||
SECRET_KEY=your-secret-key
|
||||
```
|
||||
|
||||
## 测试要求
|
||||
|
||||
- 核心业务逻辑需要单元测试
|
||||
- API 端点需要集成测试
|
||||
- 测试覆盖率目标:≥80%
|
||||
- 使用 pytest(后端)和 vitest(前端)
|
||||
|
||||
**测试示例**:
|
||||
```python
|
||||
def test_device_status_check():
|
||||
device = create_test_device()
|
||||
result = check_device_status(device)
|
||||
assert result.status in ["online", "offline"]
|
||||
```
|
||||
|
||||
## 文档要求
|
||||
|
||||
- API 变更及时更新 Swagger 文档
|
||||
- 复杂业务逻辑添加注释
|
||||
- 重要配置添加说明
|
||||
- 保持 README 和设计文档同步
|
||||
|
||||
## 开发流程
|
||||
|
||||
1. 创建功能分支:`git checkout -b feature/xxx`
|
||||
2. 开发并提交代码
|
||||
3. 运行测试:`pytest` / `npm run test`
|
||||
4. 提交 PR 并等待审核
|
||||
5. 合并到主分支
|
||||
@@ -0,0 +1,55 @@
|
||||
# 部署规则
|
||||
|
||||
## Docker 部署
|
||||
|
||||
- 使用 Docker Compose 编排
|
||||
- 外部 PostgreSQL 和 Redis(不在容器内)
|
||||
- 日志挂载到宿主机
|
||||
- 使用 Nginx 反向代理
|
||||
|
||||
**服务列表**:
|
||||
- backend: FastAPI 应用
|
||||
- celery-worker: Celery 工作进程
|
||||
- celery-beat: Celery 定时任务
|
||||
- frontend: Vue 静态文件(Nginx)
|
||||
- nginx: 反向代理
|
||||
|
||||
## 环境变量
|
||||
|
||||
**后端必需配置**:
|
||||
```bash
|
||||
DATABASE_URL # PostgreSQL 连接
|
||||
REDIS_URL # Redis 连接
|
||||
CASDOOR_* # Casdoor 认证配置
|
||||
SECRET_KEY # 应用密钥
|
||||
SSH_TIMEOUT=30 # SSH 超时时间
|
||||
```
|
||||
|
||||
**前端必需配置**:
|
||||
```bash
|
||||
VITE_API_BASE_URL # 后端 API 地址
|
||||
VITE_CASDOOR_* # Casdoor 前端配置
|
||||
```
|
||||
|
||||
## 监控告警
|
||||
|
||||
- 健康检查端点:`GET /health`
|
||||
- 性能指标端点:`GET /metrics`
|
||||
- 日志级别:生产环境使用 INFO
|
||||
- 日志轮转:10MB 每个文件,保留30天
|
||||
|
||||
## 部署流程
|
||||
|
||||
1. 配置环境变量(`.env` 文件)
|
||||
2. 构建镜像:`docker-compose build`
|
||||
3. 启动服务:`docker-compose up -d`
|
||||
4. 运行数据库迁移:`docker-compose exec backend alembic upgrade head`
|
||||
5. 检查服务状态:`docker-compose ps`
|
||||
6. 查看日志:`docker-compose logs -f`
|
||||
|
||||
## 备份策略
|
||||
|
||||
- 数据库每日自动备份
|
||||
- 备份保留30天
|
||||
- 重要操作前手动备份
|
||||
- 定期测试恢复流程
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Temp
|
||||
/tmp/
|
||||
*.tmp
|
||||
@@ -0,0 +1,335 @@
|
||||
# H3C ONU设备管理系统 - Claude开发指南
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。
|
||||
|
||||
**当前版本**: v0.5.0
|
||||
**开发状态**: 核心功能已完成
|
||||
|
||||
**已实现功能**:
|
||||
- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页)
|
||||
- ✅ Excel 数据导入和批量管理
|
||||
- ✅ 定时自动状态检查(每30分钟)+ 手动触发
|
||||
- ✅ Casdoor 统一认证和 JWT 令牌管理
|
||||
- ✅ 设备管理(列表、详情、筛选、分页)
|
||||
- ✅ 统计仪表板(总数、在线、离线)
|
||||
- ✅ 权限管理基础框架(RBAC)
|
||||
|
||||
**技术栈**:
|
||||
- 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko
|
||||
- 前端:Vue 3 + Element Plus + Pinia + Axios
|
||||
- 部署:Docker Compose
|
||||
|
||||
---
|
||||
|
||||
## 已实现的 API 端点
|
||||
|
||||
### 认证相关
|
||||
- `GET /api/auth/login` - 获取 Casdoor 登录 URL
|
||||
- `POST /api/auth/callback` - Casdoor 登录回调
|
||||
- `GET /api/auth/profile` - 获取当前用户信息
|
||||
|
||||
### 设备管理
|
||||
- `GET /api/devices` - 获取设备列表(分页、筛选)
|
||||
- `GET /api/devices/{id}` - 获取设备详情
|
||||
|
||||
### 状态检查
|
||||
- `POST /api/check/status` - 手动触发状态检查
|
||||
|
||||
### 数据导入
|
||||
- `POST /api/import/upload` - 上传并导入 Excel 文件
|
||||
|
||||
### 统计信息
|
||||
- `GET /api/stats/summary` - 获取统计摘要(总数、在线、离线)
|
||||
|
||||
### 系统
|
||||
- `GET /health` - 健康检查
|
||||
- `GET /docs` - API 文档(Swagger UI)
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
### 代码规范
|
||||
|
||||
#### Python 后端规范
|
||||
- 遵循 PEP 8 规范
|
||||
- 使用 Black 进行代码格式化
|
||||
- 使用 isort 进行导入排序
|
||||
- 使用类型注解(Type Hints)
|
||||
- 异步函数使用 async/await
|
||||
- 错误处理使用自定义异常类
|
||||
|
||||
#### Vue 前端规范
|
||||
- 使用 Composition API(setup script)
|
||||
- 组件使用 PascalCase 命名
|
||||
- 使用 TypeScript 类型检查
|
||||
- 遵循 Vue 官方风格指南
|
||||
- 使用 ESLint + Prettier 格式化
|
||||
|
||||
#### Git 提交规范
|
||||
使用 Conventional Commits 格式:
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
类型:feat, fix, docs, style, refactor, test, chore
|
||||
示例:feat(device): 添加设备导入功能
|
||||
```
|
||||
|
||||
### 架构规则
|
||||
|
||||
#### 后端架构
|
||||
- **分层架构**:API → Service → Model
|
||||
- **API 层**:仅处理请求/响应,调用 Service
|
||||
- **Service 层**:业务逻辑,不直接操作数据库
|
||||
- **Model 层**:SQLAlchemy 模型定义
|
||||
- **异步任务**:耗时操作使用 Celery
|
||||
|
||||
#### 前端架构
|
||||
- **组件分类**:
|
||||
- `components/common/`:通用组件
|
||||
- `components/business/`:业务组件
|
||||
- `views/`:页面组件
|
||||
- **状态管理**:使用 Pinia stores
|
||||
- **API 调用**:统一在 `api/` 目录封装
|
||||
|
||||
### 安全规则
|
||||
|
||||
#### 数据安全
|
||||
- SSH 密码必须加密存储(使用 Fernet 加密)
|
||||
- 敏感信息通过环境变量配置
|
||||
- 数据库连接使用 SSL
|
||||
- 定期备份数据(90天历史记录)
|
||||
|
||||
#### 应用安全
|
||||
- 所有 API 端点需要认证(除登录接口)
|
||||
- 实现 CSRF 保护
|
||||
- 输入验证使用 Pydantic
|
||||
- SQL 注入防护(使用 ORM)
|
||||
- XSS 防护(前端转义)
|
||||
|
||||
#### 访问控制
|
||||
- 基于 RBAC 的权限控制
|
||||
- 数据级权限过滤(按区域/学校)
|
||||
- 操作审计日志记录
|
||||
- 设备信息变更需要审核
|
||||
|
||||
### 性能规则
|
||||
|
||||
#### 数据库优化
|
||||
- 为常用查询字段添加索引
|
||||
- 使用连接池(pool_size=20)
|
||||
- 避免 N+1 查询问题
|
||||
- 定期清理历史数据(保留90天)
|
||||
|
||||
#### 缓存策略
|
||||
- Redis 缓存热点数据(设备状态)
|
||||
- 缓存过期时间:30分钟
|
||||
- 手动刷新有5分钟冷却限制
|
||||
|
||||
#### 异步处理
|
||||
- SSH 状态检查使用 Celery 异步任务
|
||||
- 批量导入使用后台任务
|
||||
- 定时任务使用 Celery Beat
|
||||
|
||||
### 开发规则
|
||||
|
||||
#### 环境配置
|
||||
- 开发环境使用 `.env.development`
|
||||
- 生产环境使用 `.env.production`
|
||||
- 不提交 `.env` 文件到 Git
|
||||
- 提供 `.env.example` 模板
|
||||
|
||||
#### 测试要求
|
||||
- 核心业务逻辑需要单元测试
|
||||
- API 端点需要集成测试
|
||||
- 测试覆盖率目标:≥80%
|
||||
|
||||
#### 文档要求
|
||||
- API 变更及时更新 Swagger 文档
|
||||
- 复杂业务逻辑添加注释
|
||||
- 重要配置添加说明
|
||||
|
||||
### 部署规则
|
||||
|
||||
#### Docker 部署
|
||||
- 使用 Docker Compose 编排
|
||||
- 外部 PostgreSQL 和 Redis(不在容器内)
|
||||
- 日志挂载到宿主机
|
||||
- 使用 Nginx 反向代理
|
||||
|
||||
#### 环境变量
|
||||
必需配置:
|
||||
- `DATABASE_URL`:PostgreSQL 连接字符串
|
||||
- `REDIS_URL`:Redis 连接字符串
|
||||
- `CASDOOR_*`:Casdoor 认证配置
|
||||
- `SECRET_KEY`:应用密钥
|
||||
|
||||
#### 监控告警
|
||||
- 健康检查端点:`/health`
|
||||
- 性能指标端点:`/metrics`
|
||||
- 日志级别:生产环境使用 INFO
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
H3ConuMS2/
|
||||
├── backend/ # Python 后端
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ ├── core/ # 核心配置
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── schemas/ # Pydantic 模式
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ ├── tasks/ # Celery 任务
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ ├── alembic/ # 数据库迁移
|
||||
│ └── tests/ # 测试代码
|
||||
├── frontend/ # Vue 前端
|
||||
│ └── src/
|
||||
│ ├── api/ # API 调用
|
||||
│ ├── components/ # 组件
|
||||
│ ├── router/ # 路由
|
||||
│ ├── stores/ # 状态管理
|
||||
│ ├── views/ # 页面
|
||||
│ └── utils/ # 工具函数
|
||||
├── deploy/ # 部署配置
|
||||
│ ├── docker-compose.yml
|
||||
│ └── nginx.conf
|
||||
└── docs/ # 文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心业务逻辑
|
||||
|
||||
### SSH 状态检查流程
|
||||
1. 从数据库获取 OLT 设备配置
|
||||
2. 建立 SSH 连接(使用连接池)
|
||||
3. 执行命令:`display onu slot {slot_number}`
|
||||
4. 解析返回结果:
|
||||
- 包含 "Up" → 在线
|
||||
- 包含 "Offline" → 离线
|
||||
5. 更新设备状态到数据库
|
||||
6. 记录历史状态
|
||||
|
||||
### 权限控制逻辑
|
||||
- **超级管理员**:所有权限
|
||||
- **管理员**:管理所有设备和用户
|
||||
- **区域管理员**:管理指定区域的设备
|
||||
- **学校管理员**:管理指定学校的设备
|
||||
- **普通用户**:只读权限
|
||||
|
||||
### 数据导入流程
|
||||
1. 上传 Excel 文件
|
||||
2. 使用 Pandas 解析数据
|
||||
3. 数据验证(MAC 地址格式、必填字段)
|
||||
4. 批量插入数据库
|
||||
5. 返回导入结果(成功/失败记录)
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 快速启动
|
||||
|
||||
**后端**:
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
**前端**:
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Celery Worker**:
|
||||
```bash
|
||||
cd backend
|
||||
celery -A app.core.celery_app worker --loglevel=info
|
||||
```
|
||||
|
||||
**Celery Beat**:
|
||||
```bash
|
||||
cd backend
|
||||
celery -A app.core.celery_app beat --loglevel=info
|
||||
```
|
||||
|
||||
**Docker 部署**:
|
||||
```bash
|
||||
# 配置环境变量
|
||||
cp backend/.env.example backend/.env
|
||||
|
||||
# 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 初始化数据库
|
||||
docker-compose exec backend python scripts/init_db.py
|
||||
```
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
```bash
|
||||
# 创建迁移
|
||||
alembic revision --autogenerate -m "描述"
|
||||
|
||||
# 执行迁移
|
||||
alembic upgrade head
|
||||
|
||||
# 回滚
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
### 添加新功能
|
||||
|
||||
1. **后端 API**:
|
||||
- 在 `app/api/v1/` 创建路由文件
|
||||
- 在 `app/services/` 创建服务文件
|
||||
- 在 `app/schemas/` 定义请求/响应模式
|
||||
- 在 `app/models/` 定义数据模型(如需要)
|
||||
|
||||
2. **前端页面**:
|
||||
- 在 `views/` 创建页面组件
|
||||
- 在 `api/` 添加 API 调用
|
||||
- 在 `router/` 添加路由配置
|
||||
- 在 `stores/` 添加状态管理(如需要)
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### SSH 连接失败
|
||||
- 检查网络连通性
|
||||
- 验证 SSH 凭证
|
||||
- 检查防火墙设置
|
||||
- 查看日志:`docker-compose logs backend`
|
||||
|
||||
### 数据库连接失败
|
||||
- 检查 `DATABASE_URL` 配置
|
||||
- 验证数据库服务状态
|
||||
- 检查网络权限
|
||||
|
||||
### Casdoor 登录失败
|
||||
- 检查 Casdoor 服务状态
|
||||
- 验证 `CASDOOR_*` 配置
|
||||
- 检查回调地址配置
|
||||
|
||||
---
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [FastAPI 文档](https://fastapi.tiangolo.com/)
|
||||
- [Vue 3 文档](https://vuejs.org/)
|
||||
- [Element Plus 文档](https://element-plus.org/)
|
||||
- [Casdoor 文档](https://casdoor.org/)
|
||||
- [项目详细设计](./系统设计文档.md)
|
||||
- [开发计划](./开发计划.md)
|
||||
@@ -0,0 +1,34 @@
|
||||
MAC 地址关联OLT的流程应该是这样:
|
||||
1. 先远程连接上OLT设备,然后输入 `dis onu slot 1`命令,查看olt设备中已经注册的ONU设备信息。注意有“---- More ----”的时候需要键入空格查看更多信息。
|
||||
```
|
||||
[16OLT]dis onu slot 1
|
||||
---------------------------------- Olt1/0/1 ---------------------------------
|
||||
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
|
||||
1484-778f-cb20 N/A N/A Onu1/0/1:1 N/A N/A Offline N/A
|
||||
1484-7790-2d20 N/A N/A Onu1/0/1:2 N/A N/A Offline N/A
|
||||
1484-778f-d620 N/A N/A Onu1/0/1:3 N/A N/A Offline N/A
|
||||
1484-7790-6220 N/A N/A Onu1/0/1:4 N/A N/A Offline N/A
|
||||
1484-778f-8e80 N/A N/A Onu1/0/1:5 N/A N/A Offline N/A
|
||||
1484-778f-a420 N/A N/A Onu1/0/1:6 N/A N/A Offline N/A
|
||||
---------------------------------- Olt1/0/2 ---------------------------------
|
||||
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
|
||||
1484-778e-b7e0 1 12384 Onu1/0/2:1 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778e-b4a0 2 12364 Onu1/0/2:2 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778e-a860 4 12414 Onu1/0/2:4 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778e-bce0 5 12387 Onu1/0/2:5 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778e-b400 6 12388 Onu1/0/2:6 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778f-49c0 7 12364 Onu1/0/2:7 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778f-22a0 8 12384 Onu1/0/2:8 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778e-b9c0 9 12417 Onu1/0/2:9 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778f-1aa0 N/A N/A Onu1/0/2:3 N/A N/A Offline N/A
|
||||
1484-778f-1dc0 N/A N/A Onu1/0/2:10 N/A N/A Offline N/A
|
||||
1484-778e-f1e0 N/A N/A Onu1/0/2:11 N/A N/A Offline N/A
|
||||
---------------------------------- Olt1/0/3 ---------------------------------
|
||||
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
|
||||
---- More ----
|
||||
```
|
||||
2. 将OLT返回的信息分析后返回到前端的“设备列表”,并展示给用户。比如“1484-778e-b7e0”这个MAC地址,表示的离OLT的距离是12384米,在Onu1/0/2:1端口中,它的状态是Up(在线)。只需要向前端提供这三个信息就好了。然后再将这个MAC地址关联到这台OLT下。
|
||||
3. 在数据库中创建一个表,用于存储MAC地址和olt设备之间的关联关系。这个表应该包含三个字段:MAC地址、olt设备ID和olt设备端口。
|
||||
|
||||
|
||||
1. 将设备列表页面的MAC地址变为可以点击,点击后弹出对话框,可以显示距离(12384米)、端口(Onu1/0/2:1),以及后续可能需要新增的编辑按钮、业务下发按钮等等帮我
|
||||
@@ -0,0 +1,434 @@
|
||||
# MAC关联OLT流程改进建议
|
||||
|
||||
## 文档信息
|
||||
- **分析时间**: 2026年4月2日
|
||||
- **分析对象**: `MAC关联OLT流程.md`
|
||||
- **分析重点**: 与系统设计匹配度、40个OLT设备扩展性
|
||||
- **目标读者**: 后端开发同事
|
||||
|
||||
## 一、当前流程分析
|
||||
|
||||
### 1.1 文档描述流程
|
||||
1. 远程连接OLT设备 → 执行`dis onu slot 1`命令
|
||||
2. 解析返回的ONU设备信息
|
||||
3. 提取MAC地址、距离、端口、状态信息
|
||||
4. 将信息展示给前端并关联到OLT
|
||||
|
||||
### 1.2 系统实现现状
|
||||
**匹配度评分**: 70%
|
||||
|
||||
| 功能点 | 文档描述 | 系统实现 | 状态 |
|
||||
|--------|----------|----------|------|
|
||||
| SSH连接 | 远程连接OLT | `SSHService.connect()` | ✅ 已实现 |
|
||||
| 命令执行 | `dis onu slot 1` | `execute_command()` | ✅ 已实现 |
|
||||
| 分页处理 | "---- More ----" | 空格键继续 | ✅ 已实现 |
|
||||
| MAC地址提取 | 1484-778f-cb20格式 | `parse_onu_status()` | ✅ 已实现 |
|
||||
| 状态解析 | Up/Offline | 解析状态字段 | ✅ 已实现 |
|
||||
| 距离提取 | 12384米 | ❌ 未实现 | ❌ 缺失 |
|
||||
| 端口解析 | Onu1/0/2:1 | ❌ 未完整解析 | ⚠️ 需改进 |
|
||||
| 数据关联 | 关联到OLT | `olt_id`外键 | ✅ 已实现 |
|
||||
|
||||
### 1.3 主要问题
|
||||
1. **数据提取不完整**: 当前只解析状态,未提取距离和详细端口信息
|
||||
2. **数据库字段缺失**: 缺少`distance`字段存储距离信息
|
||||
3. **端口解析不足**: 需要从"Onu1/0/2:1"解析为`slot_number`和`port_number`
|
||||
|
||||
## 二、40个OLT设备扩展性挑战
|
||||
|
||||
### 2.1 当前方案问题(串行执行)
|
||||
| 问题 | 影响程度 | 说明 |
|
||||
|------|----------|------|
|
||||
| 执行效率低 | 高 | 40个OLT串行检查,预计20-40分钟 |
|
||||
| SSH连接开销 | 中 | 每个OLT独立连接,建立/断开开销大 |
|
||||
| 网络延迟累积 | 中 | 网络不佳的OLT会阻塞整个流程 |
|
||||
| 资源占用 | 中 | 可能同时维护多个SSH连接 |
|
||||
| 错误传播 | 高 | 一个OLT失败可能影响后续检查 |
|
||||
|
||||
### 2.2 性能预估
|
||||
| 方案 | 40个OLT检查时间 | 资源占用 | 实现复杂度 | 推荐度 |
|
||||
|------|----------------|----------|------------|--------|
|
||||
| 当前串行 | ~20-40分钟 | 低 | 低 | ⭐ |
|
||||
| 并行10并发 | ~2-4分钟 | 中 | 中 | ⭐⭐⭐⭐⭐ |
|
||||
| 并行20并发 | ~1-2分钟 | 高 | 高 | ⭐⭐⭐ |
|
||||
|
||||
## 三、改进方案建议
|
||||
|
||||
### 3.1 方案一:并行处理 + 连接池(推荐)
|
||||
|
||||
#### 3.1.1 架构设计
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Celery主任务 │───▶│ OLT检查任务分发 │───▶│ 并行执行检查 │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ SSH连接池管理 │◄───│ 连接复用机制 │◄───│ 并发控制(10) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
#### 3.1.2 核心组件
|
||||
1. **任务分发器**: 将40个OLT检查拆分为独立Celery任务
|
||||
2. **连接池管理器**: 复用SSH连接,减少建立/断开开销
|
||||
3. **并发控制器**: 限制最大并发数(建议10个)
|
||||
4. **结果聚合器**: 收集各OLT检查结果,统一存储
|
||||
|
||||
#### 3.1.3 代码实现建议
|
||||
|
||||
**A. SSH连接池实现**
|
||||
```python
|
||||
# app/services/ssh_pool.py
|
||||
import paramiko
|
||||
from typing import Dict, Optional
|
||||
from threading import Lock
|
||||
|
||||
class SSHConnectionPool:
|
||||
"""SSH连接池,支持连接复用"""
|
||||
|
||||
def __init__(self, max_connections: int = 10):
|
||||
self.pool: Dict[str, paramiko.SSHClient] = {}
|
||||
self.lock = Lock()
|
||||
self.max_connections = max_connections
|
||||
|
||||
def get_connection(self, host: str, username: str, password: str) -> paramiko.SSHClient:
|
||||
"""获取或创建SSH连接"""
|
||||
key = f"{host}:{username}"
|
||||
|
||||
with self.lock:
|
||||
if key in self.pool:
|
||||
client = self.pool[key]
|
||||
# 检查连接是否仍然有效
|
||||
try:
|
||||
transport = client.get_transport()
|
||||
if transport and transport.is_active():
|
||||
return client
|
||||
except:
|
||||
pass
|
||||
|
||||
# 创建新连接
|
||||
if len(self.pool) >= self.max_connections:
|
||||
# 清理最久未使用的连接
|
||||
self._cleanup_oldest()
|
||||
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(hostname=host, username=username, password=password, timeout=30)
|
||||
|
||||
self.pool[key] = client
|
||||
return client
|
||||
|
||||
def _cleanup_oldest(self):
|
||||
"""清理最久未使用的连接"""
|
||||
# 简化实现:清理第一个连接
|
||||
if self.pool:
|
||||
key = next(iter(self.pool))
|
||||
try:
|
||||
self.pool[key].close()
|
||||
except:
|
||||
pass
|
||||
del self.pool[key]
|
||||
```
|
||||
|
||||
**B. 增强的解析器**
|
||||
```python
|
||||
# app/services/onu_parser.py
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class ONUInfo:
|
||||
"""ONU设备完整信息"""
|
||||
mac_address: str
|
||||
status: str # online/offline
|
||||
distance_m: Optional[int] = None # 距离(米)
|
||||
slot_number: Optional[int] = None # 插槽号
|
||||
port_number: Optional[int] = None # 端口号
|
||||
loid: Optional[str] = None # LOID
|
||||
model: Optional[str] = None # 设备型号
|
||||
|
||||
class ONUParser:
|
||||
"""增强的ONU信息解析器"""
|
||||
|
||||
@staticmethod
|
||||
def parse_output(output: str) -> List[ONUInfo]:
|
||||
"""解析OLT命令输出"""
|
||||
devices = []
|
||||
lines = output.split('\n')
|
||||
|
||||
current_slot = None
|
||||
|
||||
for line in lines:
|
||||
# 检测新的插槽区域
|
||||
slot_match = re.search(r'Olt(\d+)/(\d+)/(\d+)', line)
|
||||
if slot_match:
|
||||
current_slot = int(slot_match.group(1))
|
||||
continue
|
||||
|
||||
# 跳过表头行
|
||||
if 'MAC' in line and 'LOID' in line:
|
||||
continue
|
||||
|
||||
# 解析设备行
|
||||
device = ONUParser._parse_device_line(line, current_slot)
|
||||
if device:
|
||||
devices.append(device)
|
||||
|
||||
return devices
|
||||
|
||||
@staticmethod
|
||||
def _parse_device_line(line: str, slot: Optional[int]) -> Optional[ONUInfo]:
|
||||
"""解析单行设备信息"""
|
||||
# 匹配MAC地址
|
||||
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
|
||||
if not mac_match:
|
||||
return None
|
||||
|
||||
mac = mac_match.group(1).lower()
|
||||
|
||||
# 提取状态
|
||||
status = 'offline'
|
||||
if 'Up' in line:
|
||||
status = 'online'
|
||||
|
||||
# 提取距离
|
||||
distance = None
|
||||
dist_match = re.search(r'(\d+)\s*Dist\(M\)', line)
|
||||
if dist_match:
|
||||
distance = int(dist_match.group(1))
|
||||
|
||||
# 提取端口信息
|
||||
slot_num, port_num = None, None
|
||||
port_match = re.search(r'Onu(\d+)/(\d+)/(\d+):(\d+)', line)
|
||||
if port_match:
|
||||
slot_num = int(port_match.group(1))
|
||||
port_num = int(port_match.group(4))
|
||||
|
||||
# 提取LOID
|
||||
loid = None
|
||||
loid_match = re.search(r'\s+(\d+)\s+', line)
|
||||
if loid_match:
|
||||
loid = loid_match.group(1)
|
||||
|
||||
return ONUInfo(
|
||||
mac_address=mac,
|
||||
status=status,
|
||||
distance_m=distance,
|
||||
slot_number=slot_num or slot,
|
||||
port_number=port_num,
|
||||
loid=loid
|
||||
)
|
||||
```
|
||||
|
||||
**C. 并行检查任务**
|
||||
```python
|
||||
# app/tasks/parallel_check_tasks.py
|
||||
from celery import group
|
||||
from app.tasks.check_tasks import check_single_olt
|
||||
from app.models.device import OLTDevice
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
def check_all_olts_parallel(db: Session, max_concurrent: int = 10):
|
||||
"""并行检查所有OLT设备"""
|
||||
# 获取所有OLT设备
|
||||
olts = db.query(OLTDevice).all()
|
||||
|
||||
if not olts:
|
||||
return {"message": "没有可检查的OLT设备"}
|
||||
|
||||
# 创建任务组
|
||||
tasks = []
|
||||
for olt in olts:
|
||||
tasks.append(check_single_olt.s(olt.id))
|
||||
|
||||
# 分批执行,控制并发数
|
||||
results = []
|
||||
for i in range(0, len(tasks), max_concurrent):
|
||||
batch = tasks[i:i + max_concurrent]
|
||||
job = group(batch)
|
||||
result = job.apply_async()
|
||||
results.extend(result.get()) # 等待批次完成
|
||||
|
||||
return {
|
||||
"total_olts": len(olts),
|
||||
"completed": len([r for r in results if r.get("success")]),
|
||||
"failed": len([r for r in results if not r.get("success")]),
|
||||
"results": results
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 方案二:数据库优化
|
||||
|
||||
#### 3.2.1 新增字段建议
|
||||
```sql
|
||||
-- 在onu_devices表中新增字段
|
||||
ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS distance_m INTEGER;
|
||||
ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS loid VARCHAR(50);
|
||||
ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS model VARCHAR(100);
|
||||
ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS last_seen TIMESTAMP;
|
||||
|
||||
-- 在olt_devices表中新增字段
|
||||
ALTER TABLE olt_devices ADD COLUMN IF NOT EXISTS max_concurrent INTEGER DEFAULT 5;
|
||||
ALTER TABLE olt_devices ADD COLUMN IF NOT EXISTS check_timeout INTEGER DEFAULT 60;
|
||||
```
|
||||
|
||||
#### 3.2.2 数据模型更新
|
||||
```python
|
||||
# app/models/device.py - 更新ONUDevice模型
|
||||
class ONUDevice(Base):
|
||||
__tablename__ = "onu_devices"
|
||||
|
||||
# 现有字段...
|
||||
distance_m = Column(Integer) # 新增:距离(米)
|
||||
loid = Column(String(50)) # 新增:LOID
|
||||
model = Column(String(100)) # 新增:设备型号
|
||||
last_seen = Column(TIMESTAMP) # 新增:最后在线时间
|
||||
|
||||
# 索引优化
|
||||
__table_args__ = (
|
||||
Index('idx_mac_olt', 'mac_address', 'olt_id'),
|
||||
Index('idx_status_checked', 'status', 'last_seen'),
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 方案三:监控与告警增强
|
||||
|
||||
#### 3.3.1 检查任务监控
|
||||
```python
|
||||
# app/services/monitor_service.py
|
||||
class CheckMonitor:
|
||||
"""检查任务监控服务"""
|
||||
|
||||
@staticmethod
|
||||
def get_check_stats(db: Session):
|
||||
"""获取检查统计信息"""
|
||||
stats = {
|
||||
"total_olts": db.query(OLTDevice).count(),
|
||||
"total_onus": db.query(ONUDevice).count(),
|
||||
"online_onus": db.query(ONUDevice)
|
||||
.join(DeviceStatusHistory)
|
||||
.filter(DeviceStatusHistory.status == 'online')
|
||||
.distinct().count(),
|
||||
"recent_checks": db.query(DeviceStatusHistory)
|
||||
.order_by(DeviceStatusHistory.checked_at.desc())
|
||||
.limit(10).all()
|
||||
}
|
||||
return stats
|
||||
|
||||
@staticmethod
|
||||
def check_olt_health(olt: OLTDevice) -> Dict:
|
||||
"""检查OLT健康状态"""
|
||||
# 测试连接
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
start_time = time.time()
|
||||
ssh.connect()
|
||||
connect_time = time.time() - start_time
|
||||
|
||||
# 执行简单命令测试
|
||||
output = ssh.execute_command("display version")
|
||||
|
||||
return {
|
||||
"olt_id": olt.id,
|
||||
"status": "healthy",
|
||||
"connect_time": connect_time,
|
||||
"response_time": len(output) / 1024, # KB/s
|
||||
"timestamp": datetime.utcnow()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"olt_id": olt.id,
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.utcnow()
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
```
|
||||
|
||||
## 四、实施计划建议
|
||||
|
||||
### 4.1 第一阶段:基础改进(1-2天)
|
||||
1. 增强ONU信息解析器,提取完整字段
|
||||
2. 更新数据库模型,新增必要字段
|
||||
3. 修改现有检查逻辑,存储完整信息
|
||||
|
||||
### 4.2 第二阶段:并行化改造(3-5天)
|
||||
1. 实现SSH连接池
|
||||
2. 改造Celery任务为并行执行
|
||||
3. 添加并发控制和错误处理
|
||||
4. 测试10个OLT并发场景
|
||||
|
||||
### 4.3 第三阶段:优化与监控(2-3天)
|
||||
1. 添加检查任务监控
|
||||
2. 实现健康检查机制
|
||||
3. 优化数据库查询性能
|
||||
4. 添加告警通知功能
|
||||
|
||||
### 4.4 第四阶段:压力测试(1-2天)
|
||||
1. 模拟40个OLT并发检查
|
||||
2. 测试网络异常情况
|
||||
3. 验证系统稳定性
|
||||
4. 性能调优
|
||||
|
||||
## 五、风险与应对
|
||||
|
||||
### 5.1 技术风险
|
||||
| 风险 | 可能性 | 影响 | 应对措施 |
|
||||
|------|--------|------|----------|
|
||||
| OLT连接超时 | 中 | 高 | 实现超时重试机制 |
|
||||
| 并发连接过多 | 低 | 中 | 限制最大并发数 |
|
||||
| 内存泄漏 | 低 | 高 | 添加连接池清理机制 |
|
||||
| 数据库锁竞争 | 中 | 中 | 优化事务处理,使用批量操作 |
|
||||
|
||||
### 5.2 业务风险
|
||||
| 风险 | 可能性 | 影响 | 应对措施 |
|
||||
|------|--------|------|----------|
|
||||
| 检查结果不一致 | 低 | 中 | 添加数据校验机制 |
|
||||
| 检查时间过长 | 中 | 高 | 实现增量检查,优化查询 |
|
||||
| 用户等待时间 | 中 | 中 | 提供进度查询接口 |
|
||||
|
||||
## 六、预期收益
|
||||
|
||||
### 6.1 性能提升
|
||||
- **检查时间**: 从20-40分钟缩短到2-4分钟(10倍提升)
|
||||
- **资源利用率**: SSH连接复用减少50%连接开销
|
||||
- **系统稳定性**: 错误隔离避免单点故障影响全局
|
||||
|
||||
### 6.2 功能增强
|
||||
- **数据完整性**: 完整记录距离、端口、LOID等信息
|
||||
- **监控能力**: 实时监控检查任务状态和OLT健康度
|
||||
- **扩展性**: 支持未来扩展到更多OLT设备
|
||||
|
||||
### 6.3 运维改善
|
||||
- **故障诊断**: 详细的检查日志和错误信息
|
||||
- **性能分析**: 检查任务性能统计和趋势分析
|
||||
- **容量规划**: 基于实际负载的资源规划依据
|
||||
|
||||
## 七、后续建议
|
||||
|
||||
### 7.1 短期建议(1个月内)
|
||||
1. 实现基础并行检查功能
|
||||
2. 完成数据库字段扩展
|
||||
3. 添加基础监控告警
|
||||
|
||||
### 7.2 中期建议(1-3个月)
|
||||
1. 实现智能调度(基于OLT负载和网络状况)
|
||||
2. 添加检查结果分析和报表
|
||||
3. 集成自动化运维工具
|
||||
|
||||
### 7.3 长期建议(3-6个月)
|
||||
1. 机器学习预测设备故障
|
||||
2. 自动化故障恢复机制
|
||||
3. 多数据中心部署支持
|
||||
|
||||
---
|
||||
|
||||
**文档维护**:
|
||||
- 本文档应随系统改进同步更新
|
||||
- 重大架构变更需更新本文档
|
||||
- 实际实施中的调整应记录在案
|
||||
|
||||
**联系方式**:
|
||||
- 如有疑问或建议,请通过项目沟通渠道反馈
|
||||
- 实施过程中遇到问题及时记录并分享解决方案
|
||||
@@ -0,0 +1,32 @@
|
||||
<16OLT>dis onu slot 1
|
||||
---------------------------------- Olt1/0/1 ---------------------------------
|
||||
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
|
||||
1484-7790-5200 12 <1000 Onu1/0/1:1 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-7790-4e20 13 <1000 Onu1/0/1:2 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-7790-3900 N/A N/A Onu1/0/1:3 N/A N/A Offline N/A
|
||||
---------------------------------- Olt1/0/2 ---------------------------------
|
||||
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
|
||||
34dc-99c8-8320 5 <1000 Onu1/0/2:1 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778f-7b80 N/A N/A Onu1/0/2:2 N/A N/A Offline N/A
|
||||
---------------------------------- Olt1/0/3 ---------------------------------
|
||||
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
|
||||
1484-778f-ba60 3 <1000 Onu1/0/3:1 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778f-5480 2 <1000 Onu1/0/3:2 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778f-eb80 N/A N/A Onu1/0/3:3 N/A N/A Offline N/A
|
||||
1484-778f-b280 N/A N/A Onu1/0/3:4 N/A N/A Offline N/A
|
||||
1484-778f-00c0 N/A N/A Onu1/0/3:5 N/A N/A Offline N/A
|
||||
---------------------------------- Olt1/0/4 ---------------------------------
|
||||
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
|
||||
1484-778f-8960 13 <1000 Onu1/0/4:1 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-778f-e9e0 8 <1000 Onu1/0/4:2 WA6520H-EGPON/A 106/ Up N/A
|
||||
---- More ----
|
||||
|
||||
|
||||
OLT可能会遇到多个MAC地址在不同端口的情况,这个情况是因为之前这个MAC已经在某个端口上,但是后来因为其他原因更换了端口,但是旧的端口上还有它的记录。如果是这种情况,需要把这个MAC记录下来,在OLT管理页面中新增一个“重复MAC地址”的按钮,后续可以通过命令删除这些旧的MAC地址(后期再实现)。
|
||||
|
||||
再帮我优化一下设备列表页面:
|
||||
1. “状态”新增“未知”选项
|
||||
2. 点击“全部更新”按钮后,执行所有OLT扫描功能,并更新设备状态(不需要入库,只需要更新MAC的在线情况和距离)。
|
||||
|
||||
再帮我优化一下OLT管理功能:
|
||||
1.
|
||||
@@ -0,0 +1,580 @@
|
||||
# 项目结构详细说明
|
||||
|
||||
## 后端目录结构 (backend/)
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/ # 主应用目录
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI应用入口
|
||||
│ ├── api/ # API路由
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── v1/ # API版本1
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── auth.py # 认证相关API
|
||||
│ │ │ ├── devices.py # 设备管理API
|
||||
│ │ │ ├── check.py # 状态检查API
|
||||
│ │ │ ├── stats.py # 统计API
|
||||
│ │ │ ├── users.py # 用户管理API
|
||||
│ │ │ ├── import.py # 数据导入API
|
||||
│ │ │ └── system.py # 系统设置API
|
||||
│ │ └── dependencies.py # 依赖注入
|
||||
│ ├── core/ # 核心配置
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── config.py # 应用配置
|
||||
│ │ ├── security.py # 安全相关
|
||||
│ │ ├── database.py # 数据库配置
|
||||
│ │ ├── casdoor.py # Casdoor配置
|
||||
│ │ └── celery_app.py # Celery配置
|
||||
│ ├── models/ # SQLAlchemy模型
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py # 基础模型
|
||||
│ │ ├── device.py # 设备相关模型
|
||||
│ │ ├── user.py # 用户相关模型
|
||||
│ │ ├── permission.py # 权限相关模型
|
||||
│ │ └── history.py # 历史记录模型
|
||||
│ ├── schemas/ # Pydantic模式
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── device.py # 设备模式
|
||||
│ │ ├── user.py # 用户模式
|
||||
│ │ ├── auth.py # 认证模式
|
||||
│ │ └── common.py # 通用模式
|
||||
│ ├── services/ # 业务逻辑服务
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── device_service.py # 设备服务
|
||||
│ │ ├── ssh_service.py # SSH连接服务
|
||||
│ │ ├── check_service.py # 状态检查服务
|
||||
│ │ ├── import_service.py # 数据导入服务
|
||||
│ │ ├── user_service.py # 用户服务
|
||||
│ │ ├── permission_service.py # 权限服务
|
||||
│ │ └── casdoor_service.py # Casdoor服务
|
||||
│ ├── tasks/ # Celery任务
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── check_tasks.py # 状态检查任务
|
||||
│ │ ├── import_tasks.py # 数据导入任务
|
||||
│ │ └── notification_tasks.py # 通知任务
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── excel_parser.py # Excel解析工具
|
||||
│ │ ├── ssh_utils.py # SSH工具函数
|
||||
│ │ ├── validators.py # 数据验证
|
||||
│ │ ├── encryption.py # 加密工具
|
||||
│ │ └── logger.py # 日志配置
|
||||
│ └── middleware/ # 中间件
|
||||
│ ├── __init__.py
|
||||
│ ├── auth_middleware.py # 认证中间件
|
||||
│ ├── permission_middleware.py # 权限中间件
|
||||
│ └── logging_middleware.py # 日志中间件
|
||||
├── alembic/ # 数据库迁移
|
||||
│ ├── versions/ # 迁移版本
|
||||
│ ├── env.py
|
||||
│ └── alembic.ini
|
||||
├── tests/ # 测试代码
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # 测试配置
|
||||
│ ├── test_api/ # API测试
|
||||
│ ├── test_services/ # 服务测试
|
||||
│ └── test_utils/ # 工具测试
|
||||
├── scripts/ # 脚本文件
|
||||
│ ├── init_db.py # 数据库初始化
|
||||
│ ├── create_admin.py # 创建管理员
|
||||
│ └── backup_data.py # 数据备份
|
||||
├── requirements/ # 依赖管理
|
||||
│ ├── base.txt # 基础依赖
|
||||
│ ├── dev.txt # 开发依赖
|
||||
│ └── prod.txt # 生产依赖
|
||||
├── logs/ # 日志目录
|
||||
├── static/ # 静态文件
|
||||
├── Dockerfile # Docker构建文件
|
||||
├── docker-entrypoint.sh # Docker入口脚本
|
||||
├── requirements.txt # 依赖文件
|
||||
├── .env.example # 环境变量示例
|
||||
└── pyproject.toml # Python项目配置
|
||||
```
|
||||
|
||||
## 前端目录结构 (frontend/)
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── public/ # 静态资源
|
||||
│ ├── index.html # 主HTML文件
|
||||
│ ├── favicon.ico # 网站图标
|
||||
│ └── robots.txt # 搜索引擎配置
|
||||
├── src/ # 源代码
|
||||
│ ├── main.js # 应用入口
|
||||
│ ├── App.vue # 根组件
|
||||
│ ├── api/ # API调用
|
||||
│ │ ├── index.js # API配置
|
||||
│ │ ├── auth.js # 认证API
|
||||
│ │ ├── device.js # 设备API
|
||||
│ │ ├── check.js # 状态检查API
|
||||
│ │ ├── user.js # 用户API
|
||||
│ │ └── import.js # 数据导入API
|
||||
│ ├── assets/ # 资源文件
|
||||
│ │ ├── css/ # 样式文件
|
||||
│ │ │ ├── main.css # 主样式
|
||||
│ │ │ ├── variables.css # CSS变量
|
||||
│ │ │ └── components.css # 组件样式
|
||||
│ │ └── images/ # 图片资源
|
||||
│ ├── components/ # 公共组件
|
||||
│ │ ├── common/ # 通用组件
|
||||
│ │ │ ├── Layout/ # 布局组件
|
||||
│ │ │ │ ├── AppLayout.vue
|
||||
│ │ │ │ ├── Header.vue
|
||||
│ │ │ │ ├── Sidebar.vue
|
||||
│ │ │ │ └── Footer.vue
|
||||
│ │ │ ├── Table/ # 表格组件
|
||||
│ │ │ │ ├── DataTable.vue
|
||||
│ │ │ │ └── Pagination.vue
|
||||
│ │ │ ├── Form/ # 表单组件
|
||||
│ │ │ │ ├── SearchForm.vue
|
||||
│ │ │ │ └── FilterForm.vue
|
||||
│ │ │ ├── Chart/ # 图表组件
|
||||
│ │ │ │ ├── StatusChart.vue
|
||||
│ │ │ │ └── TrendChart.vue
|
||||
│ │ │ └── Dialog/ # 对话框组件
|
||||
│ │ │ ├── ConfirmDialog.vue
|
||||
│ │ │ └── ImportDialog.vue
|
||||
│ │ └── business/ # 业务组件
|
||||
│ │ ├── Device/ # 设备相关组件
|
||||
│ │ ├── User/ # 用户相关组件
|
||||
│ │ └── System/ # 系统相关组件
|
||||
│ ├── router/ # 路由配置
|
||||
│ │ ├── index.js # 路由主文件
|
||||
│ │ ├── routes.js # 路由定义
|
||||
│ │ └── guards.js # 路由守卫
|
||||
│ ├── stores/ # 状态管理 (Pinia)
|
||||
│ │ ├── index.js # Store配置
|
||||
│ │ ├── auth.js # 认证状态
|
||||
│ │ ├── device.js # 设备状态
|
||||
│ │ ├── user.js # 用户状态
|
||||
│ │ └── system.js # 系统状态
|
||||
│ ├── views/ # 页面组件
|
||||
│ │ ├── Auth/ # 认证页面
|
||||
│ │ │ ├── Login.vue # 登录页面
|
||||
│ │ │ └── Callback.vue # 回调页面
|
||||
│ │ ├── Dashboard/ # 仪表板
|
||||
│ │ │ └── index.vue # 仪表板主页
|
||||
│ │ ├── Device/ # 设备管理
|
||||
│ │ │ ├── List.vue # 设备列表
|
||||
│ │ │ ├── Detail.vue # 设备详情
|
||||
│ │ │ └── Import.vue # 数据导入
|
||||
│ │ ├── Check/ # 状态检查
|
||||
│ │ │ ├── Status.vue # 状态页面
|
||||
│ │ │ └── History.vue # 检查历史
|
||||
│ │ ├── User/ # 用户管理
|
||||
│ │ │ ├── List.vue # 用户列表
|
||||
│ │ │ ├── Profile.vue # 用户资料
|
||||
│ │ │ └── Permission.vue # 权限管理
|
||||
│ │ ├── System/ # 系统设置
|
||||
│ │ │ ├── OltConfig.vue # OLT配置
|
||||
│ │ │ ├── TaskConfig.vue # 任务配置
|
||||
│ │ │ └── Audit.vue # 审核中心
|
||||
│ │ └── Error/ # 错误页面
|
||||
│ │ ├── 404.vue # 404页面
|
||||
│ │ └── 500.vue # 500页面
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── auth.js # 认证工具
|
||||
│ │ ├── request.js # 请求工具
|
||||
│ │ ├── permission.js # 权限工具
|
||||
│ │ ├── format.js # 格式化工具
|
||||
│ │ ├── validate.js # 验证工具
|
||||
│ │ └── storage.js # 存储工具
|
||||
│ ├── directives/ # 自定义指令
|
||||
│ │ ├── permission.js # 权限指令
|
||||
│ │ └── loading.js # 加载指令
|
||||
│ └── plugins/ # 插件
|
||||
│ ├── element-plus.js # Element Plus插件
|
||||
│ └── echarts.js # ECharts插件
|
||||
├── tests/ # 测试文件
|
||||
│ ├── unit/ # 单元测试
|
||||
│ └── e2e/ # 端到端测试
|
||||
├── .env.development # 开发环境变量
|
||||
├── .env.production # 生产环境变量
|
||||
├── .env.example # 环境变量示例
|
||||
├── package.json # 项目配置
|
||||
├── package-lock.json # 依赖锁文件
|
||||
├── vite.config.js # Vite配置
|
||||
├── index.html # HTML入口
|
||||
└── Dockerfile # Docker构建文件
|
||||
```
|
||||
|
||||
## 部署目录结构 (deploy/)
|
||||
|
||||
```
|
||||
deploy/
|
||||
├── docker-compose.yml # Docker Compose配置
|
||||
├── docker-compose.dev.yml # 开发环境配置
|
||||
├── docker-compose.prod.yml # 生产环境配置
|
||||
├── nginx/ # Nginx配置
|
||||
│ ├── nginx.conf # 主配置文件
|
||||
│ ├── conf.d/ # 站点配置
|
||||
│ │ └── h3c-onu-ms.conf # 应用配置
|
||||
│ └── ssl/ # SSL证书
|
||||
├── scripts/ # 部署脚本
|
||||
│ ├── deploy.sh # 部署脚本
|
||||
│ ├── backup.sh # 备份脚本
|
||||
│ ├── restore.sh # 恢复脚本
|
||||
│ └── monitor.sh # 监控脚本
|
||||
├── config/ # 配置文件
|
||||
│ ├── backend.env # 后端环境变量
|
||||
│ ├── frontend.env # 前端环境变量
|
||||
│ └── celery.env # Celery环境变量
|
||||
└── .env.example # 环境变量示例
|
||||
```
|
||||
|
||||
## 文档目录结构 (docs/)
|
||||
|
||||
```
|
||||
docs/
|
||||
├── api/ # API文档
|
||||
│ ├── overview.md # API概述
|
||||
│ ├── auth.md # 认证API文档
|
||||
│ ├── device.md # 设备API文档
|
||||
│ ├── check.md # 状态检查API文档
|
||||
│ └── user.md # 用户API文档
|
||||
├── deployment/ # 部署文档
|
||||
│ ├── requirements.md # 环境要求
|
||||
│ ├── installation.md # 安装指南
|
||||
│ ├── configuration.md # 配置说明
|
||||
│ ├── docker.md # Docker部署
|
||||
│ └── troubleshooting.md # 故障排除
|
||||
├── user-guide/ # 用户指南
|
||||
│ ├── getting-started.md # 快速开始
|
||||
│ ├── device-management.md # 设备管理指南
|
||||
│ ├── data-import.md # 数据导入指南
|
||||
│ ├── permission-guide.md # 权限管理指南
|
||||
│ └── faq.md # 常见问题
|
||||
├── development/ # 开发文档
|
||||
│ ├── setup.md # 开发环境搭建
|
||||
│ ├── architecture.md # 架构说明
|
||||
│ ├── coding-standards.md # 编码规范
|
||||
│ ├── testing.md # 测试指南
|
||||
│ └── contributing.md # 贡献指南
|
||||
└── images/ # 文档图片
|
||||
```
|
||||
|
||||
## 脚本目录结构 (scripts/)
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── database/ # 数据库脚本
|
||||
│ ├── init.sql # 初始化SQL
|
||||
│ ├── backup.sh # 数据库备份
|
||||
│ ├── restore.sh # 数据库恢复
|
||||
│ └── migrate.sh # 迁移脚本
|
||||
├── monitoring/ # 监控脚本
|
||||
│ ├── health-check.sh # 健康检查
|
||||
│ ├── log-rotate.sh # 日志轮转
|
||||
│ └── alert.sh # 告警脚本
|
||||
├── deployment/ # 部署脚本
|
||||
│ ├── build.sh # 构建脚本
|
||||
│ ├── deploy.sh # 部署脚本
|
||||
│ └── rollback.sh # 回滚脚本
|
||||
└── utils/ # 工具脚本
|
||||
├── cleanup.sh # 清理脚本
|
||||
├── generate-cert.sh # 证书生成
|
||||
└── update-config.sh # 配置更新
|
||||
```
|
||||
|
||||
## 环境变量说明
|
||||
|
||||
### 后端环境变量 (.env)
|
||||
```bash
|
||||
# 应用配置
|
||||
APP_NAME=H3C-ONU-MS
|
||||
APP_ENV=production
|
||||
DEBUG=false
|
||||
SECRET_KEY=your-secret-key-here
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=postgresql://user:password@host:5432/dbname
|
||||
DATABASE_POOL_SIZE=20
|
||||
DATABASE_MAX_OVERFLOW=40
|
||||
|
||||
# Redis配置
|
||||
REDIS_URL=redis://host:6379/0
|
||||
REDIS_POOL_SIZE=10
|
||||
|
||||
# Casdoor配置
|
||||
CASDOOR_ENDPOINT=https://casdoor.example.com
|
||||
CASDOOR_CLIENT_ID=your_client_id
|
||||
CASDOOR_CLIENT_SECRET=your_client_secret
|
||||
CASDOOR_CERTIFICATE=your_certificate
|
||||
CASDOOR_ORG_NAME=your_org
|
||||
CASDOOR_APP_NAME=h3c-onu-ms
|
||||
|
||||
# SSH配置
|
||||
SSH_TIMEOUT=30
|
||||
SSH_MAX_CONNECTIONS=10
|
||||
SSH_RETRY_COUNT=3
|
||||
|
||||
# 任务配置
|
||||
CHECK_INTERVAL=1800 # 30分钟(秒)
|
||||
MANUAL_COOLDOWN=300 # 5分钟(秒)
|
||||
HISTORY_RETENTION=90 # 90天
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=/app/logs/app.log
|
||||
LOG_ROTATION=10MB
|
||||
LOG_RETENTION=30
|
||||
```
|
||||
|
||||
### 前端环境变量 (.env)
|
||||
```bash
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=H3C ONU设备管理系统
|
||||
VITE_APP_VERSION=1.0.0
|
||||
|
||||
# API配置
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
VITE_API_TIMEOUT=30000
|
||||
|
||||
# Casdoor配置
|
||||
VITE_CASDOOR_ENDPOINT=https://casdoor.example.com
|
||||
VITE_CASDOOR_CLIENT_ID=your_client_id
|
||||
VITE_CASDOOR_ORG_NAME=your_org
|
||||
VITE_CASDOOR_APP_NAME=h3c-onu-ms
|
||||
|
||||
# 功能开关
|
||||
VITE_ENABLE_SSO=true
|
||||
VITE_ENABLE_WEBSOCKET=true
|
||||
VITE_ENABLE_ANALYTICS=false
|
||||
```
|
||||
|
||||
## 开发工作流
|
||||
|
||||
### 1. 环境准备
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone <repository-url>
|
||||
cd H3ConuMS2
|
||||
|
||||
# 后端环境
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements/dev.txt
|
||||
|
||||
# 前端环境
|
||||
cd ../frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 数据库初始化
|
||||
```bash
|
||||
# 创建数据库
|
||||
createdb h3c_onu_ms
|
||||
|
||||
# 运行迁移
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
|
||||
# 初始化数据
|
||||
python scripts/init_data.py
|
||||
```
|
||||
|
||||
### 3. 启动开发服务
|
||||
```bash
|
||||
# 启动后端
|
||||
cd backend
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# 启动前端(新终端)
|
||||
cd frontend
|
||||
npm run dev
|
||||
|
||||
# 启动Celery Worker(新终端)
|
||||
cd backend
|
||||
celery -A app.core.celery_app worker --loglevel=info
|
||||
|
||||
# 启动Celery Beat(新终端)
|
||||
cd backend
|
||||
celery -A app.core.celery_app beat --loglevel=info
|
||||
```
|
||||
|
||||
### 4. 访问应用
|
||||
- 前端开发服务器: http://localhost:5173
|
||||
- 后端API服务器: http://localhost:8000
|
||||
- API文档: http://localhost:8000/docs
|
||||
- 管理界面: http://localhost:8000/admin
|
||||
|
||||
## 代码规范
|
||||
|
||||
### Python代码规范
|
||||
- 遵循PEP 8规范
|
||||
- 使用Black进行代码格式化
|
||||
- 使用isort进行导入排序
|
||||
- 使用Flake8进行代码检查
|
||||
- 使用mypy进行类型检查
|
||||
|
||||
### Vue代码规范
|
||||
- 使用ESLint + Prettier
|
||||
- 遵循Vue官方风格指南
|
||||
- 使用TypeScript进行类型检查
|
||||
- 组件使用PascalCase命名
|
||||
- 单文件组件结构规范
|
||||
|
||||
### Git提交规范
|
||||
- 使用Conventional Commits规范
|
||||
- 提交信息格式: `<type>(<scope>): <subject>`
|
||||
- 类型: feat, fix, docs, style, refactor, test, chore
|
||||
- 示例: `feat(device): 添加设备导入功能`
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试
|
||||
```bash
|
||||
# 后端单元测试
|
||||
cd backend
|
||||
pytest tests/unit/
|
||||
|
||||
# 前端单元测试
|
||||
cd frontend
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### 集成测试
|
||||
```bash
|
||||
# 后端集成测试
|
||||
cd backend
|
||||
pytest tests/integration/
|
||||
|
||||
# API测试
|
||||
pytest tests/api/
|
||||
```
|
||||
|
||||
### 端到端测试
|
||||
```bash
|
||||
# 前端E2E测试
|
||||
cd frontend
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## 部署流程
|
||||
|
||||
### 开发环境部署
|
||||
```bash
|
||||
cd deploy
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
### 生产环境部署
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# 启动服务
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
```
|
||||
|
||||
### 持续集成/持续部署
|
||||
1. 代码推送到Git仓库
|
||||
2. 自动运行测试
|
||||
3. 构建Docker镜像
|
||||
4. 推送到镜像仓库
|
||||
5. 部署到服务器
|
||||
6. 运行健康检查
|
||||
|
||||
## 监控和告警
|
||||
|
||||
### 应用监控
|
||||
- 健康检查端点: `/health`
|
||||
- 性能指标: `/metrics` (Prometheus格式)
|
||||
- 请求统计: 中间件记录
|
||||
- 错误跟踪: Sentry集成
|
||||
|
||||
### 系统监控
|
||||
- 服务器资源使用率
|
||||
- 数据库连接池状态
|
||||
- Redis内存使用情况
|
||||
- 网络连接状态
|
||||
|
||||
### 告警规则
|
||||
- 设备离线率超过阈值
|
||||
- 系统资源使用率过高
|
||||
- 服务不可用
|
||||
- 安全相关事件
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### 数据安全
|
||||
- SSH密码加密存储
|
||||
- 数据库连接加密
|
||||
- 敏感信息环境变量管理
|
||||
- 定期数据备份
|
||||
|
||||
### 应用安全
|
||||
- CSRF保护
|
||||
- XSS防护
|
||||
- SQL注入防护
|
||||
- 速率限制
|
||||
- 输入验证
|
||||
|
||||
### 访问安全
|
||||
- 基于角色的访问控制
|
||||
- 会话管理
|
||||
- 双因素认证支持
|
||||
- 登录尝试限制
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 数据库优化
|
||||
- 合理使用索引
|
||||
- 查询优化
|
||||
- 连接池配置
|
||||
- 定期清理历史数据
|
||||
|
||||
### 缓存策略
|
||||
- Redis缓存热点数据
|
||||
- 浏览器缓存静态资源
|
||||
- CDN加速前端资源
|
||||
|
||||
### 异步处理
|
||||
- Celery处理耗时任务
|
||||
- WebSocket实时更新
|
||||
- 批量操作优化
|
||||
|
||||
## 扩展性设计
|
||||
|
||||
### 水平扩展
|
||||
- 无状态API服务
|
||||
- 数据库读写分离
|
||||
- Redis集群支持
|
||||
- 负载均衡配置
|
||||
|
||||
### 功能扩展
|
||||
- 插件化架构设计
|
||||
- 模块化代码组织
|
||||
- 配置驱动功能开关
|
||||
- API版本管理
|
||||
|
||||
## 维护计划
|
||||
|
||||
### 日常维护
|
||||
- 日志监控和分析
|
||||
- 数据库备份验证
|
||||
- 系统更新和补丁
|
||||
- 性能监控和优化
|
||||
|
||||
### 定期维护
|
||||
- 每月安全审计
|
||||
- 每季度性能评估
|
||||
- 每年架构评审
|
||||
- 数据归档和清理
|
||||
|
||||
## 文档更新
|
||||
- API变更及时更新文档
|
||||
- 部署流程变更记录
|
||||
- 故障处理经验总结
|
||||
- 用户反馈整理
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2026年4月1日
|
||||
**维护人员**: 系统开发团队
|
||||
@@ -0,0 +1,37 @@
|
||||
# H3C ONU设备管理系统 - 快速开始
|
||||
|
||||
## 项目状态
|
||||
✅ 第一阶段:基础框架已完成
|
||||
|
||||
## 已完成功能
|
||||
- ✅ 后端 FastAPI 框架
|
||||
- ✅ 前端 Vue 3 框架
|
||||
- ✅ SSH 连接和 ONU 状态解析
|
||||
- ✅ 数据库模型设计
|
||||
- ✅ Docker 配置
|
||||
|
||||
## 快速启动
|
||||
|
||||
### 1. 配置环境变量
|
||||
```bash
|
||||
cp backend/.env.example backend/.env
|
||||
# 编辑 backend/.env 配置数据库等信息
|
||||
```
|
||||
|
||||
### 2. 启动后端
|
||||
```bash
|
||||
./start-backend.sh
|
||||
```
|
||||
|
||||
### 3. 启动前端
|
||||
```bash
|
||||
./start-frontend.sh
|
||||
```
|
||||
|
||||
### 4. 访问系统
|
||||
- 前端:http://localhost:5173
|
||||
- 后端 API:http://localhost:8000
|
||||
- API 文档:http://localhost:8000/docs
|
||||
|
||||
## 下一步开发
|
||||
参考 `开发计划.md` 继续第一阶段第2周任务
|
||||
@@ -0,0 +1,282 @@
|
||||
# H3C ONU设备管理系统
|
||||
|
||||
基于Python FastAPI + Vue 3的H3C OLT设备监控管理系统,用于监控和管理4000+ ONU设备的在线状态。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 核心功能
|
||||
- ✅ H3C OLT设备SSH连接和状态查询
|
||||
- ✅ ONU设备在线状态监控(4000+设备)
|
||||
- ✅ Excel数据导入和批量管理
|
||||
- ✅ 定时自动状态检查(每30分钟)
|
||||
- ✅ 手动刷新功能(5分钟冷却限制)
|
||||
- ✅ 90天状态历史记录
|
||||
|
||||
### 权限管理
|
||||
- ✅ Casdoor统一认证集成
|
||||
- ✅ 基于RBAC的多级权限控制
|
||||
- ✅ 区域/学校级别的数据隔离
|
||||
- ✅ 设备信息变更审核流程
|
||||
- ✅ 用户角色和权限管理
|
||||
|
||||
### 界面功能
|
||||
- ✅ 响应式Web界面
|
||||
- ✅ 设备列表表格展示
|
||||
- ✅ 高级筛选和搜索
|
||||
- ✅ 实时统计图表
|
||||
- ✅ 设备详情和历史趋势
|
||||
- ✅ 数据导入和管理界面
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端
|
||||
- **Python FastAPI** - 高性能异步Web框架
|
||||
- **PostgreSQL** - 关系型数据库
|
||||
- **Paramiko** - SSH连接库
|
||||
- **Celery + Redis** - 异步任务队列
|
||||
- **Pandas** - Excel数据处理
|
||||
- **SQLAlchemy** - ORM框架
|
||||
- **Casdoor SDK** - 统一认证
|
||||
|
||||
### 前端
|
||||
- **Vue 3 + Composition API** - 前端框架
|
||||
- **Element Plus** - UI组件库
|
||||
- **Axios** - HTTP客户端
|
||||
- **Vue Router** - 路由管理
|
||||
- **Pinia** - 状态管理
|
||||
- **ECharts** - 图表库
|
||||
|
||||
### 部署
|
||||
- **Docker + Docker Compose** - 容器化部署
|
||||
- **Nginx** - 反向代理
|
||||
- **外部PostgreSQL/Redis** - 数据库服务
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- 外部PostgreSQL数据库
|
||||
- 外部Redis服务
|
||||
- Casdoor认证服务器
|
||||
|
||||
### 配置步骤
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd H3ConuMS2
|
||||
```
|
||||
|
||||
2. **配置环境变量**
|
||||
```bash
|
||||
cp deploy/.env.example deploy/.env
|
||||
# 编辑deploy/.env文件,配置数据库和Casdoor信息
|
||||
```
|
||||
|
||||
3. **启动服务**
|
||||
```bash
|
||||
cd deploy
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **访问系统**
|
||||
- 前端界面: http://localhost:8080
|
||||
- API文档: http://localhost:8000/docs
|
||||
- 健康检查: http://localhost:8000/health
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
H3ConuMS2/
|
||||
├── backend/ # Python后端代码
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API路由
|
||||
│ │ ├── core/ # 核心配置
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── schemas/ # Pydantic模式
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ ├── tasks/ # Celery任务
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ ├── alembic/ # 数据库迁移
|
||||
│ ├── tests/ # 测试代码
|
||||
│ ├── requirements.txt # Python依赖
|
||||
│ └── Dockerfile # 后端Dockerfile
|
||||
├── frontend/ # Vue前端代码
|
||||
│ ├── public/ # 静态资源
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API调用
|
||||
│ │ ├── assets/ # 资源文件
|
||||
│ │ ├── components/ # 组件
|
||||
│ │ ├── router/ # 路由配置
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ ├── package.json # 前端依赖
|
||||
│ └── Dockerfile # 前端Dockerfile
|
||||
├── deploy/ # 部署配置
|
||||
│ ├── docker-compose.yml # Docker编排
|
||||
│ ├── nginx.conf # Nginx配置
|
||||
│ └── .env.example # 环境变量示例
|
||||
├── docs/ # 文档
|
||||
│ ├── api/ # API文档
|
||||
│ ├── deployment/ # 部署文档
|
||||
│ └── user-guide/ # 用户指南
|
||||
├── scripts/ # 脚本文件
|
||||
│ ├── init-db.sh # 数据库初始化
|
||||
│ ├── backup.sh # 数据备份
|
||||
│ └── monitor.sh # 系统监控
|
||||
└── 系统设计文档.md # 详细设计文档
|
||||
```
|
||||
|
||||
## 数据库初始化
|
||||
|
||||
1. **创建数据库**
|
||||
```sql
|
||||
CREATE DATABASE h3c_onu_ms;
|
||||
CREATE USER h3c_user WITH PASSWORD 'your_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE h3c_onu_ms TO h3c_user;
|
||||
```
|
||||
|
||||
2. **运行迁移**
|
||||
```bash
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
3. **初始化数据**
|
||||
```bash
|
||||
python scripts/init_data.py
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### Casdoor配置
|
||||
1. 在Casdoor中创建应用
|
||||
2. 配置回调地址: `http://your-domain/api/auth/callback`
|
||||
3. 获取Client ID、Client Secret和Certificate
|
||||
4. 在.env文件中配置Casdoor参数
|
||||
|
||||
### OLT设备配置
|
||||
1. 通过系统界面添加OLT设备
|
||||
2. 配置SSH连接信息(IP、用户名、密码)
|
||||
3. 设置插槽命令(如`display onu slot 1`)
|
||||
|
||||
### 权限配置
|
||||
1. 在Casdoor中配置用户角色
|
||||
2. 在系统中配置角色权限映射
|
||||
3. 分配用户区域和学校权限
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 后端开发
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# venv\Scripts\activate # Windows
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 前端开发
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 测试
|
||||
```bash
|
||||
# 后端测试
|
||||
cd backend
|
||||
pytest
|
||||
|
||||
# 前端测试
|
||||
cd frontend
|
||||
npm run test
|
||||
```
|
||||
|
||||
## API文档
|
||||
|
||||
启动后端服务后,访问以下地址查看API文档:
|
||||
- Swagger UI: http://localhost:8000/docs
|
||||
- ReDoc: http://localhost:8000/redoc
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 健康检查
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
|
||||
### 指标监控
|
||||
```
|
||||
GET /metrics
|
||||
```
|
||||
|
||||
### 日志查看
|
||||
```bash
|
||||
# 查看后端日志
|
||||
docker-compose logs backend
|
||||
|
||||
# 查看Celery日志
|
||||
docker-compose logs celery-worker
|
||||
|
||||
# 查看Nginx日志
|
||||
docker-compose logs nginx
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **SSH连接失败**
|
||||
- 检查网络连通性
|
||||
- 验证SSH凭证
|
||||
- 检查防火墙设置
|
||||
|
||||
2. **数据库连接失败**
|
||||
- 检查数据库服务状态
|
||||
- 验证连接字符串
|
||||
- 检查网络权限
|
||||
|
||||
3. **Casdoor登录失败**
|
||||
- 检查Casdoor服务状态
|
||||
- 验证应用配置
|
||||
- 检查回调地址配置
|
||||
|
||||
### 日志级别
|
||||
- DEBUG: 开发环境详细日志
|
||||
- INFO: 生产环境常规日志
|
||||
- ERROR: 错误信息记录
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建Pull Request
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用MIT许可证 - 查看LICENSE文件了解详情。
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 项目维护者: [Your Name]
|
||||
- 问题反馈: [GitHub Issues]
|
||||
- 文档更新: [GitHub Wiki]
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (计划中)
|
||||
- 基础设备监控功能
|
||||
- Casdoor认证集成
|
||||
- 权限管理系统
|
||||
- Docker化部署
|
||||
|
||||
---
|
||||
|
||||
**重要提示**: 生产环境部署前,请务必修改默认密码和密钥,并配置适当的防火墙规则。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 应用配置
|
||||
APP_NAME=H3C-ONU-MS
|
||||
DEBUG=false
|
||||
SECRET_KEY=your-secret-key-change-this
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms
|
||||
|
||||
# Redis配置
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# Casdoor配置
|
||||
CASDOOR_ENDPOINT=https://casdoor.example.com
|
||||
CASDOOR_CLIENT_ID=your_client_id
|
||||
CASDOOR_CLIENT_SECRET=your_client_secret
|
||||
CASDOOR_ORG_NAME=your_org
|
||||
CASDOOR_APP_NAME=h3c-onu-ms
|
||||
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
|
||||
CASDOOR_REDIRECT_URL=http://localhost:5173/callback
|
||||
|
||||
# SSH配置
|
||||
SSH_TIMEOUT=30
|
||||
|
||||
# 任务配置
|
||||
CHECK_INTERVAL=1800
|
||||
MANUAL_COOLDOWN=300
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 使用清华镜像源
|
||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
|
||||
pip config set global.trusted-host https://pypi.tuna.tsinghua.edu.cn
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,36 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -0,0 +1,40 @@
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
from app.models import device
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""add location to olt_devices
|
||||
|
||||
Revision ID: 32c84173ef05
|
||||
Revises: 5c24b07e0f9c
|
||||
Create Date: 2026-04-02 10:52:19.883293
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '32c84173ef05'
|
||||
down_revision: Union[str, None] = '5c24b07e0f9c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.add_column('olt_devices', sa.Column('location', sa.String(length=200), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('olt_devices', 'location')
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
|
||||
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
|
||||
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,47 @@
|
||||
"""add port_id to onu_devices
|
||||
|
||||
Revision ID: 3e108afa9bba
|
||||
Revises: add_onu_extended_fields
|
||||
Create Date: 2026-04-02 20:30:23.475918
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3e108afa9bba'
|
||||
down_revision: Union[str, None] = 'add_onu_extended_fields'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.add_column('onu_devices', sa.Column('port_id', sa.String(length=20), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('onu_devices', 'port_id')
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
|
||||
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
|
||||
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,66 @@
|
||||
"""add place_type and notes fields
|
||||
|
||||
Revision ID: 5c24b07e0f9c
|
||||
Revises:
|
||||
Create Date: 2026-04-01 17:51:21.451465
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5c24b07e0f9c'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('role_permissions')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_table('permissions')
|
||||
op.add_column('onu_devices', sa.Column('place_type', sa.String(length=50), nullable=True))
|
||||
op.drop_column('onu_devices', 'location_type')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('onu_devices', sa.Column('location_type', sa.VARCHAR(length=100), autoincrement=False, nullable=True))
|
||||
op.drop_column('onu_devices', 'place_type')
|
||||
op.create_table('role_permissions',
|
||||
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('permission_id', sa.BIGINT(), autoincrement=False, nullable=False),
|
||||
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], name=op.f('role_permissions_permission_id_fkey')),
|
||||
sa.PrimaryKeyConstraint('role', 'permission_id', name=op.f('role_permissions_pkey'))
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
|
||||
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
|
||||
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_table('permissions',
|
||||
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('code', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('module', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('permissions_pkey')),
|
||||
sa.UniqueConstraint('code', name=op.f('permissions_code_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""添加距离、LOID、型号等扩展字段
|
||||
|
||||
Revision ID: add_onu_extended_fields
|
||||
Revises: 32c84173ef05
|
||||
Create Date: 2026-04-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = 'add_onu_extended_fields'
|
||||
down_revision = '32c84173ef05'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ONUDevice 表新增字段
|
||||
op.add_column('onu_devices', sa.Column('loid', sa.String(50), nullable=True))
|
||||
op.add_column('onu_devices', sa.Column('model', sa.String(100), nullable=True))
|
||||
op.add_column('onu_devices', sa.Column('distance_m', sa.Integer, nullable=True))
|
||||
|
||||
# DeviceStatusHistory 表新增字段
|
||||
op.add_column('device_status_history', sa.Column('distance_m', sa.Integer, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('device_status_history', 'distance_m')
|
||||
op.drop_column('onu_devices', 'distance_m')
|
||||
op.drop_column('onu_devices', 'model')
|
||||
op.drop_column('onu_devices', 'loid')
|
||||
@@ -0,0 +1,88 @@
|
||||
"""认证 API"""
|
||||
import base64
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.casdoor import casdoor_sdk
|
||||
from app.core.security import create_access_token
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import Token, UserInfo
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
|
||||
def decode_jwt_payload(token: str) -> dict:
|
||||
"""直接解码 JWT payload,不验签(Casdoor 已完成认证)"""
|
||||
payload_b64 = token.split(".")[1]
|
||||
rem = len(payload_b64) % 4
|
||||
if rem:
|
||||
payload_b64 += "=" * (4 - rem)
|
||||
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def login():
|
||||
"""获取 Casdoor 登录 URL"""
|
||||
return {"url": casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)}
|
||||
|
||||
|
||||
class CallbackRequest(BaseModel):
|
||||
code: str
|
||||
state: str
|
||||
|
||||
|
||||
@router.post("/callback", response_model=Token)
|
||||
def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
||||
"""Casdoor 登录回调"""
|
||||
try:
|
||||
token_response = casdoor_sdk.get_oauth_token(code=body.code)
|
||||
if isinstance(token_response, dict) and "error" in token_response:
|
||||
raise HTTPException(status_code=400, detail=token_response.get("error_description", token_response["error"]))
|
||||
|
||||
access_token = token_response.get("access_token") if isinstance(token_response, dict) else token_response
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token")
|
||||
|
||||
casdoor_user = decode_jwt_payload(access_token)
|
||||
|
||||
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
|
||||
if not user:
|
||||
user = User(
|
||||
casdoor_id=casdoor_user["sub"],
|
||||
username=casdoor_user.get("name") or casdoor_user.get("preferred_username", ""),
|
||||
email=casdoor_user.get("email"),
|
||||
role="user"
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
user.last_login = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
jwt_token = create_access_token({"sub": str(user.id), "username": user.username})
|
||||
return {"access_token": jwt_token}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/profile", response_model=UserInfo)
|
||||
def get_profile(token: str, db: Session = Depends(get_db)):
|
||||
"""获取当前用户信息"""
|
||||
from app.core.security import verify_token
|
||||
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="无效的令牌")
|
||||
|
||||
user = db.query(User).filter(User.id == int(payload["sub"])).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""状态检查 API"""
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from celery.result import AsyncResult
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from app.tasks.check_tasks import check_all_devices
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.database import get_db
|
||||
from app.services.check_service import CheckService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/check", tags=["状态检查"])
|
||||
|
||||
|
||||
class CheckResult(BaseModel):
|
||||
olt_id: int
|
||||
olt_name: str
|
||||
online: Optional[int] = 0
|
||||
offline: Optional[int] = 0
|
||||
success: bool
|
||||
|
||||
|
||||
class CheckError(BaseModel):
|
||||
olt_id: int
|
||||
olt_name: str
|
||||
error: str
|
||||
|
||||
|
||||
@router.post("/status")
|
||||
def trigger_check():
|
||||
"""手动触发状态检查"""
|
||||
try:
|
||||
task = check_all_devices.delay()
|
||||
return {"task_id": task.id, "status": "started"}
|
||||
except Exception as e:
|
||||
logger.error(f"触发状态检查失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"触发状态检查失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/status/{task_id}")
|
||||
def get_check_status(task_id: str):
|
||||
"""查询状态检查任务进度和结果"""
|
||||
task_result = AsyncResult(task_id, app=celery_app)
|
||||
state = task_result.state
|
||||
|
||||
result = {
|
||||
"task_id": task_id,
|
||||
"status": state,
|
||||
"progress": None,
|
||||
"result": None
|
||||
}
|
||||
|
||||
if state == 'PROGRESS':
|
||||
result["progress"] = task_result.info
|
||||
|
||||
if state == 'SUCCESS':
|
||||
result["result"] = task_result.result
|
||||
elif state == 'FAILURE':
|
||||
result["error"] = str(task_result.info)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/scan/{olt_id}")
|
||||
def scan_olt(olt_id: int, db: Session = Depends(get_db)):
|
||||
"""扫描单台 OLT,预览发现的设备(不写入数据库)"""
|
||||
try:
|
||||
service = CheckService(db)
|
||||
result = asyncio.run(service.scan_olt(olt_id))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/discover/{olt_id}")
|
||||
def discover_olt(olt_id: int, db: Session = Depends(get_db)):
|
||||
"""扫描单台 OLT 并将新发现的 MAC 自动入库关联"""
|
||||
try:
|
||||
service = CheckService(db)
|
||||
result = asyncio.run(service.check_olt_devices(olt_id))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,231 @@
|
||||
"""设备管理 API"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import asc, desc, distinct, or_
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.core.database import get_db
|
||||
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice
|
||||
from app.schemas.device import DeviceListResponse, ONUDeviceResponse
|
||||
|
||||
router = APIRouter(prefix="/api/devices", tags=["设备管理"])
|
||||
|
||||
|
||||
@router.get("", response_model=DeviceListResponse)
|
||||
def get_devices(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
region: str = None,
|
||||
school_name: str = None,
|
||||
keyword: str = None,
|
||||
status: str = None, # online / offline
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取设备列表"""
|
||||
# 子查询:每台设备最新一条状态记录
|
||||
from sqlalchemy import func
|
||||
latest_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
||||
)
|
||||
.group_by(DeviceStatusHistory.onu_device_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_history = (
|
||||
db.query(DeviceStatusHistory)
|
||||
.join(
|
||||
latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
query = db.query(ONUDevice)
|
||||
|
||||
if region:
|
||||
query = query.filter(ONUDevice.region == region)
|
||||
if school_name:
|
||||
query = query.filter(ONUDevice.school_name.contains(school_name))
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
or_(
|
||||
ONUDevice.mac_address.contains(keyword.lower()),
|
||||
ONUDevice.building.contains(keyword),
|
||||
ONUDevice.place_type.contains(keyword),
|
||||
ONUDevice.school_name.contains(keyword),
|
||||
ONUDevice.region.contains(keyword),
|
||||
ONUDevice.room_number.contains(keyword),
|
||||
)
|
||||
)
|
||||
if status in ("online", "offline", "unknown"):
|
||||
if status in ("online", "offline"):
|
||||
query = query.join(
|
||||
latest_history,
|
||||
ONUDevice.id == latest_history.c.onu_device_id
|
||||
).filter(latest_history.c.status == status)
|
||||
else:
|
||||
# unknown:最新状态为 unknown,或没有任何状态记录
|
||||
query = query.outerjoin(
|
||||
latest_history,
|
||||
ONUDevice.id == latest_history.c.onu_device_id
|
||||
).filter(
|
||||
or_(
|
||||
latest_history.c.onu_device_id == None,
|
||||
latest_history.c.status == "unknown"
|
||||
)
|
||||
)
|
||||
|
||||
# 多级排序:区域 > 学校名称 > 楼宇 > 房间号(均为升序)
|
||||
query = query.order_by(
|
||||
asc(ONUDevice.region),
|
||||
asc(ONUDevice.school_name),
|
||||
asc(ONUDevice.building),
|
||||
asc(ONUDevice.room_number)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
|
||||
# 获取每个设备最新的状态(批量,避免 N+1)
|
||||
device_ids = [item.id for item in items]
|
||||
history_map = {}
|
||||
if device_ids:
|
||||
histories = (
|
||||
db.query(DeviceStatusHistory)
|
||||
.join(
|
||||
latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||
)
|
||||
.filter(DeviceStatusHistory.onu_device_id.in_(device_ids))
|
||||
.all()
|
||||
)
|
||||
history_map = {h.onu_device_id: h for h in histories}
|
||||
|
||||
# 批量加载 OLT 信息
|
||||
olt_ids = {item.olt_id for item in items if item.olt_id}
|
||||
olt_map = {}
|
||||
if olt_ids:
|
||||
olts = db.query(OLTDevice).filter(OLTDevice.id.in_(olt_ids)).all()
|
||||
olt_map = {o.id: o for o in olts}
|
||||
|
||||
result_items = []
|
||||
for item in items:
|
||||
latest_status = history_map.get(item.id)
|
||||
olt = olt_map.get(item.olt_id)
|
||||
|
||||
item_dict = {
|
||||
"id": item.id,
|
||||
"mac_address": item.mac_address,
|
||||
"olt_id": item.olt_id,
|
||||
"region": item.region,
|
||||
"school_name": item.school_name,
|
||||
"building": item.building,
|
||||
"place_type": item.place_type,
|
||||
"room_number": item.room_number,
|
||||
"notes": item.notes,
|
||||
"status": latest_status.status if latest_status else None,
|
||||
"distance_m": latest_status.distance_m if latest_status else None,
|
||||
"slot_number": item.slot_number,
|
||||
"port_number": item.port_number,
|
||||
"port_id": item.port_id,
|
||||
"model": item.model,
|
||||
"olt_location": olt.location if olt else None,
|
||||
"created_at": item.created_at
|
||||
}
|
||||
result_items.append(ONUDeviceResponse(**item_dict))
|
||||
|
||||
return {"total": total, "items": result_items}
|
||||
|
||||
|
||||
@router.get("/regions")
|
||||
def get_regions(db: Session = Depends(get_db)):
|
||||
"""获取所有区域列表"""
|
||||
regions = db.query(distinct(ONUDevice.region)).filter(
|
||||
ONUDevice.region.isnot(None),
|
||||
ONUDevice.region != ''
|
||||
).order_by(ONUDevice.region).all()
|
||||
return [r[0] for r in regions]
|
||||
|
||||
|
||||
@router.get("/{device_id}", response_model=ONUDeviceResponse)
|
||||
def get_device(device_id: int, db: Session = Depends(get_db)):
|
||||
"""获取设备详情"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
latest_status = db.query(DeviceStatusHistory).filter(
|
||||
DeviceStatusHistory.onu_device_id == device.id
|
||||
).order_by(desc(DeviceStatusHistory.checked_at)).first()
|
||||
|
||||
olt = db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first() if device.olt_id else None
|
||||
|
||||
return ONUDeviceResponse(
|
||||
id=device.id,
|
||||
mac_address=device.mac_address,
|
||||
olt_id=device.olt_id,
|
||||
region=device.region,
|
||||
school_name=device.school_name,
|
||||
building=device.building,
|
||||
place_type=device.place_type,
|
||||
room_number=device.room_number,
|
||||
notes=device.notes,
|
||||
status=latest_status.status if latest_status else None,
|
||||
distance_m=latest_status.distance_m if latest_status else None,
|
||||
slot_number=device.slot_number,
|
||||
port_number=device.port_number,
|
||||
port_id=device.port_id,
|
||||
model=device.model,
|
||||
olt_location=olt.location if olt else None,
|
||||
created_at=device.created_at
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{device_id}/refresh")
|
||||
def refresh_device_status(device_id: int, db: Session = Depends(get_db)):
|
||||
"""通过 SSH 单独更新一台设备的状态和距离"""
|
||||
from app.services.check_service import CheckService
|
||||
try:
|
||||
service = CheckService(db)
|
||||
result = service.check_single_device(device_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
region: Optional[str] = None
|
||||
school_name: Optional[str] = None
|
||||
building: Optional[str] = None
|
||||
room_number: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@router.delete("/status/all")
|
||||
def clear_all_status(db: Session = Depends(get_db)):
|
||||
"""清空所有设备状态历史记录"""
|
||||
db.query(DeviceStatusHistory).delete()
|
||||
db.commit()
|
||||
return {"message": "已清空所有设备状态"}
|
||||
|
||||
|
||||
@router.put("/{device_id}")
|
||||
def update_device(device_id: int, body: DeviceUpdate, db: Session = Depends(get_db)):
|
||||
"""更新设备信息(区域、学校、楼宇、房间号、备注)"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
device.region = body.region
|
||||
device.school_name = body.school_name
|
||||
device.building = body.building or None
|
||||
device.room_number = body.room_number or None
|
||||
device.notes = body.notes or None
|
||||
db.commit()
|
||||
return {"message": "更新成功"}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""数据导入 API"""
|
||||
from fastapi import APIRouter, UploadFile, File, Depends, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.services.import_service import ImportService
|
||||
import shutil
|
||||
import io
|
||||
from openpyxl import Workbook
|
||||
|
||||
router = APIRouter(prefix="/api/import", tags=["数据导入"])
|
||||
|
||||
|
||||
@router.get("/template")
|
||||
def download_template():
|
||||
"""下载导入数据模板"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "ONU设备导入模板"
|
||||
|
||||
# 表头:序号|区域|学校名称|楼宇|场所类型|房间号|MAC地址|备注
|
||||
headers = ["mac_address", "region", "school_name", "building", "place_type", "room_number", "notes"]
|
||||
ws.append(headers)
|
||||
|
||||
# 示例数据
|
||||
example_data = [
|
||||
["AA:BB:CC:DD:EE:01", "区域1", "学校1", "1号楼", "教室", "101", ""],
|
||||
["AA:BB:CC:DD:EE:02", "区域1", "学校1", "1号楼", "办公室", "102", ""],
|
||||
]
|
||||
for row in example_data:
|
||||
ws.append(row)
|
||||
|
||||
# 设置列宽
|
||||
ws.column_dimensions['A'].width = 20 # mac_address
|
||||
ws.column_dimensions['B'].width = 12 # region
|
||||
ws.column_dimensions['C'].width = 18 # school_name
|
||||
ws.column_dimensions['D'].width = 12 # building
|
||||
ws.column_dimensions['E'].width = 12 # place_type
|
||||
ws.column_dimensions['F'].width = 12 # room_number
|
||||
ws.column_dimensions['G'].width = 20 # notes
|
||||
|
||||
# 保存到内存
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=onu_import_template.xlsx"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_excel(
|
||||
file: UploadFile = File(...),
|
||||
olt_id: int = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""上传并导入 Excel 文件(仅导入 MAC 信息,不关联 OLT)"""
|
||||
file_path = f"/tmp/{file.filename}"
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
service = ImportService(db)
|
||||
records = service.parse_excel(file_path)
|
||||
validation = service.validate_data(records)
|
||||
|
||||
success_count = 0
|
||||
if validation['valid']:
|
||||
result = service.import_devices(validation['valid'], olt_id)
|
||||
success_count = result['success']
|
||||
|
||||
return {
|
||||
"success": success_count,
|
||||
"failed": validation['invalid']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
"""OLT 设备管理 API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.models.device import OLTDevice
|
||||
import pandas as pd
|
||||
import io
|
||||
|
||||
router = APIRouter(prefix="/api/olt", tags=["OLT设备"])
|
||||
|
||||
|
||||
class OLTCreate(BaseModel):
|
||||
ip_address: str
|
||||
username: str
|
||||
password: str
|
||||
slot_command: str = "display onu slot"
|
||||
location: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
class OLTEdit(BaseModel):
|
||||
username: str
|
||||
password: str = None
|
||||
slot_command: str = "display onu slot"
|
||||
location: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
@router.get("/devices")
|
||||
def get_devices(db: Session = Depends(get_db)):
|
||||
return db.query(OLTDevice).all()
|
||||
|
||||
|
||||
@router.post("/devices")
|
||||
def create_device(device: OLTCreate, db: Session = Depends(get_db)):
|
||||
db_device = OLTDevice(**device.dict())
|
||||
db.add(db_device)
|
||||
db.commit()
|
||||
return {"message": "创建成功"}
|
||||
|
||||
|
||||
@router.put("/devices/{ip_address}")
|
||||
def update_device(ip_address: str, device: OLTEdit, db: Session = Depends(get_db)):
|
||||
db_device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
|
||||
if not db_device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
db_device.username = device.username
|
||||
if device.password:
|
||||
db_device.password = device.password
|
||||
db_device.slot_command = device.slot_command
|
||||
db_device.location = device.location
|
||||
db_device.description = device.description
|
||||
|
||||
db.commit()
|
||||
return {"message": "更新成功"}
|
||||
|
||||
|
||||
@router.delete("/devices/{ip_address}")
|
||||
def delete_device(ip_address: str, db: Session = Depends(get_db)):
|
||||
from app.models.device import ONUDevice
|
||||
|
||||
device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
# 检查是否有关联的 ONU 设备
|
||||
onu_count = db.query(ONUDevice).filter(ONUDevice.olt_id == device.id).count()
|
||||
if onu_count > 0:
|
||||
raise HTTPException(status_code=400, detail=f"该 OLT 设备下还有 {onu_count} 个 ONU 设备,无法删除")
|
||||
|
||||
db.delete(device)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_devices(file: UploadFile = File(...), db: Session = Depends(get_db)):
|
||||
try:
|
||||
content = await file.read()
|
||||
df = pd.read_excel(io.BytesIO(content))
|
||||
# 标准化列名
|
||||
df.columns = [str(c).strip() for c in df.columns]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"文件解析失败: {str(e)}")
|
||||
|
||||
required_cols = ['IP地址', '用户名', '密码']
|
||||
missing = [c for c in required_cols if c not in df.columns]
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"缺少必填列: {', '.join(missing)},当前列: {', '.join(df.columns.tolist())}")
|
||||
|
||||
success, failed = 0, []
|
||||
for idx, row in df.iterrows():
|
||||
try:
|
||||
ip = str(row['IP地址']).strip()
|
||||
if not ip or ip == 'nan':
|
||||
continue
|
||||
existing = db.query(OLTDevice).filter(OLTDevice.ip_address == ip).first()
|
||||
if existing:
|
||||
failed.append({"row": idx + 2, "ip": ip, "reason": "IP 已存在"})
|
||||
continue
|
||||
device = OLTDevice(
|
||||
ip_address=ip,
|
||||
username=str(row['用户名']).strip(),
|
||||
password=str(row['密码']).strip(),
|
||||
slot_command=str(row['槽位命令']).strip() if '槽位命令' in df.columns and str(row['槽位命令']) != 'nan' else 'display onu slot',
|
||||
location=str(row['安装位置']).strip() if '安装位置' in df.columns and str(row['安装位置']) != 'nan' else '',
|
||||
description=str(row['描述']).strip() if '描述' in df.columns and str(row['描述']) != 'nan' else '',
|
||||
)
|
||||
db.add(device)
|
||||
success += 1
|
||||
except Exception as e:
|
||||
failed.append({"row": idx + 2, "ip": str(row.get('IP地址', '')), "reason": str(e)})
|
||||
|
||||
db.commit()
|
||||
return {"message": f"成功导入 {success} 条记录", "success": success, "failed": failed}
|
||||
|
||||
|
||||
@router.get("/template")
|
||||
def download_template():
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(
|
||||
path="/home/v6ole/pyproject/H3ConuMS2/backend/templates/OLT设备导入模板.xlsx",
|
||||
filename="OLT设备导入模板.xlsx"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/duplicate-macs")
|
||||
def get_duplicate_macs(olt_id: int = None, db: Session = Depends(get_db)):
|
||||
"""查询重复 MAC 地址记录"""
|
||||
from app.models.device import DuplicateMac
|
||||
query = db.query(DuplicateMac)
|
||||
if olt_id:
|
||||
query = query.filter(DuplicateMac.olt_id == olt_id)
|
||||
records = query.order_by(DuplicateMac.last_seen_at.desc()).all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"olt_id": r.olt_id,
|
||||
"mac_address": r.mac_address,
|
||||
"ports": r.ports,
|
||||
"first_seen_at": r.first_seen_at,
|
||||
"last_seen_at": r.last_seen_at,
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/duplicate-macs/{record_id}")
|
||||
def delete_duplicate_mac(record_id: int, db: Session = Depends(get_db)):
|
||||
"""删除重复 MAC 记录(已处理后清除)"""
|
||||
from app.models.device import DuplicateMac
|
||||
record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
db.delete(record)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
class ClearPortRequest(BaseModel):
|
||||
port_id: str # 如 "1/0/1:3"
|
||||
|
||||
|
||||
@router.post("/duplicate-macs/{record_id}/clear-port")
|
||||
def clear_onu_port(record_id: int, body: ClearPortRequest, db: Session = Depends(get_db)):
|
||||
"""通过 SSH 清除指定端口的 ONU 配置,并从 ports 列表中移除该端口"""
|
||||
from app.models.device import DuplicateMac
|
||||
from app.services.ssh_service import SSHService
|
||||
|
||||
record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
olt = db.query(OLTDevice).filter(OLTDevice.id == record.olt_id).first()
|
||||
if not olt:
|
||||
raise HTTPException(status_code=404, detail="OLT 设备不存在")
|
||||
|
||||
# 验证 port_id 在记录中
|
||||
port_ids = [p["port_id"] for p in (record.ports or [])]
|
||||
if body.port_id not in port_ids:
|
||||
raise HTTPException(status_code=400, detail="端口不在重复记录中")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
ssh.clear_onu_port(body.port_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
# 从 ports 列表移除已清除的端口
|
||||
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
|
||||
if remaining:
|
||||
record.ports = remaining
|
||||
else:
|
||||
# 所有端口都清除了,删除整条记录
|
||||
db.delete(record)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"端口 Onu{body.port_id} 已清除", "remaining_ports": remaining}
|
||||
|
||||
|
||||
@router.get("/new-devices")
|
||||
def get_new_devices(db: Session = Depends(get_db)):
|
||||
"""查询新发现的设备列表(待补全信息)"""
|
||||
from app.models.device import NewDevice, ONUDevice
|
||||
rows = (
|
||||
db.query(NewDevice, ONUDevice)
|
||||
.join(ONUDevice, NewDevice.onu_device_id == ONUDevice.id)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": nd.id,
|
||||
"onu_device_id": nd.onu_device_id,
|
||||
"olt_id": nd.olt_id,
|
||||
"discovered_at": nd.discovered_at,
|
||||
"mac_address": onu.mac_address,
|
||||
"port_id": f"{onu.slot_number}/{onu.port_number}" if onu.slot_number else None,
|
||||
"loid": onu.loid,
|
||||
"model": onu.model,
|
||||
"region": onu.region,
|
||||
"school_name": onu.school_name,
|
||||
"building": onu.building,
|
||||
"room_number": onu.room_number,
|
||||
}
|
||||
for nd, onu in rows
|
||||
]
|
||||
|
||||
|
||||
class NewDeviceUpdate(BaseModel):
|
||||
region: str
|
||||
school_name: str
|
||||
building: str = ""
|
||||
place_type: str = ""
|
||||
room_number: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@router.put("/new-devices/{record_id}")
|
||||
def update_new_device(record_id: int, body: NewDeviceUpdate, db: Session = Depends(get_db)):
|
||||
"""补全新设备信息,完成后从 new_devices 移除"""
|
||||
from app.models.device import NewDevice, ONUDevice
|
||||
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
onu = db.query(ONUDevice).filter(ONUDevice.id == record.onu_device_id).first()
|
||||
if not onu:
|
||||
raise HTTPException(status_code=404, detail="ONU 设备不存在")
|
||||
|
||||
onu.region = body.region
|
||||
onu.school_name = body.school_name
|
||||
onu.building = body.building or None
|
||||
onu.place_type = body.place_type or None
|
||||
onu.room_number = body.room_number or None
|
||||
onu.notes = body.notes or None
|
||||
|
||||
db.delete(record)
|
||||
db.commit()
|
||||
return {"message": "信息已补全"}
|
||||
|
||||
|
||||
@router.delete("/new-devices/{record_id}")
|
||||
def dismiss_new_device(record_id: int, db: Session = Depends(get_db)):
|
||||
"""忽略新设备(不补全信息,仅从待处理列表移除)"""
|
||||
from app.models.device import NewDevice
|
||||
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
db.delete(record)
|
||||
db.commit()
|
||||
return {"message": "已忽略"}
|
||||
|
||||
|
||||
@router.post("/quick-scan")
|
||||
def quick_scan(db: Session = Depends(get_db)):
|
||||
"""多线程对所有 OLT 同时执行扫描,更新已有设备状态"""
|
||||
from app.services.check_service import CheckService
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
olts = db.query(OLTDevice).all()
|
||||
|
||||
def scan_one(olt):
|
||||
from app.core.database import SessionLocal
|
||||
thread_db = SessionLocal()
|
||||
try:
|
||||
service = CheckService(thread_db)
|
||||
result = service.scan_and_discover(olt.id)
|
||||
return {"olt_id": olt.id, "olt_location": olt.location or olt.ip_address,
|
||||
"online": result.get("online", 0), "offline": result.get("offline", 0),
|
||||
"new_discovered": result.get("new_discovered", 0),
|
||||
"error": None}
|
||||
except Exception as e:
|
||||
return {"olt_id": olt.id, "olt_location": olt.location or olt.ip_address,
|
||||
"online": 0, "offline": 0, "new_discovered": 0, "error": str(e)}
|
||||
finally:
|
||||
thread_db.close()
|
||||
|
||||
olt_results = {}
|
||||
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
||||
futures = {executor.submit(scan_one, olt): olt.id for olt in olts}
|
||||
for future in as_completed(futures):
|
||||
r = future.result()
|
||||
olt_results[r["olt_id"]] = r
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
total_online = 0
|
||||
total_offline = 0
|
||||
total_new = 0
|
||||
for olt in olts:
|
||||
r = olt_results.get(olt.id, {})
|
||||
if r.get("error"):
|
||||
errors.append({"olt_location": r["olt_location"], "error": r["error"]})
|
||||
else:
|
||||
total_online += r.get("online", 0)
|
||||
total_offline += r.get("offline", 0)
|
||||
total_new += r.get("new_discovered", 0)
|
||||
results.append({
|
||||
"olt_location": r.get("olt_location", olt.location or olt.ip_address),
|
||||
"online": r.get("online", 0),
|
||||
"offline": r.get("offline", 0),
|
||||
"new_discovered": r.get("new_discovered", 0),
|
||||
"success": not r.get("error"),
|
||||
"error": r.get("error"),
|
||||
})
|
||||
|
||||
return {
|
||||
"total_online": total_online,
|
||||
"total_offline": total_offline,
|
||||
"total_new": total_new,
|
||||
"results": results,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/loopback-detection")
|
||||
def loopback_detection(db: Session = Depends(get_db)):
|
||||
"""对所有 OLT 并发执行环路检测,返回有环路的端口及对应设备信息"""
|
||||
from app.models.device import ONUDevice
|
||||
from app.services.ssh_service import SSHService
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
olts = db.query(OLTDevice).all()
|
||||
|
||||
# 预加载所有 ONU 设备,按 (olt_id, port_id) 索引,避免多线程操作 Session
|
||||
all_onus = db.query(ONUDevice).all()
|
||||
onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id}
|
||||
|
||||
def check_one(olt):
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
detection = ssh.detect_loopback()
|
||||
except Exception as e:
|
||||
return {
|
||||
"olt_id": olt.id,
|
||||
"olt_ip": olt.ip_address,
|
||||
"olt_location": olt.location or olt.ip_address,
|
||||
"error": str(e),
|
||||
"has_loop": False,
|
||||
"loop_interfaces": [],
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
loop_interfaces = []
|
||||
for iface in detection.get("interfaces", []):
|
||||
port_id = iface.removeprefix("Onu")
|
||||
onu = onu_map.get((olt.id, port_id))
|
||||
loop_interfaces.append({
|
||||
"interface": iface,
|
||||
"port_id": port_id,
|
||||
"mac_address": onu.mac_address if onu else None,
|
||||
"region": onu.region if onu else None,
|
||||
"school_name": onu.school_name if onu else None,
|
||||
"building": onu.building if onu else None,
|
||||
"room_number": onu.room_number if onu else None,
|
||||
})
|
||||
|
||||
return {
|
||||
"olt_id": olt.id,
|
||||
"olt_ip": olt.ip_address,
|
||||
"olt_location": olt.location or olt.ip_address,
|
||||
"has_loop": detection["has_loop"],
|
||||
"loop_interfaces": loop_interfaces,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
results_map = {}
|
||||
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
||||
futures = {executor.submit(check_one, olt): olt.id for olt in olts}
|
||||
for future in as_completed(futures):
|
||||
olt_id = futures[future]
|
||||
results_map[olt_id] = future.result()
|
||||
|
||||
# 按原始顺序返回
|
||||
return [results_map[olt.id] for olt in olts]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""业务下发 API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from app.core.database import get_db
|
||||
from app.services.provision_service import ProvisionService
|
||||
|
||||
router = APIRouter(prefix="/api/provision", tags=["业务下发"])
|
||||
|
||||
|
||||
class ProvisionRequest(BaseModel):
|
||||
device_id: int
|
||||
|
||||
|
||||
class BatchProvisionRequest(BaseModel):
|
||||
device_ids: List[int]
|
||||
|
||||
|
||||
class ProvisionResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
device_id: Optional[int] = None
|
||||
mac_address: Optional[str] = None
|
||||
olt_ip: Optional[str] = None
|
||||
port: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/service", response_model=ProvisionResponse)
|
||||
def provision_single_device(
|
||||
request: ProvisionRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""下发业务到单个设备"""
|
||||
service = ProvisionService(db)
|
||||
result = service.provision_device(request.device_id)
|
||||
|
||||
if result.get("success"):
|
||||
return ProvisionResponse(
|
||||
success=True,
|
||||
message=f"业务下发成功,MAC: {result['mac_address']}, 端口: {result['port']}",
|
||||
device_id=result["device_id"],
|
||||
mac_address=result["mac_address"],
|
||||
olt_ip=result.get("olt_ip"),
|
||||
port=result.get("port")
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "业务下发失败"))
|
||||
|
||||
|
||||
@router.post("/batch", response_model=dict)
|
||||
def provision_batch_devices(
|
||||
request: BatchProvisionRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""批量下发业务"""
|
||||
service = ProvisionService(db)
|
||||
result = service.batch_provision(request.device_ids)
|
||||
return result
|
||||
@@ -0,0 +1,118 @@
|
||||
"""统计 API"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, case
|
||||
from app.core.database import get_db
|
||||
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["统计"])
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def get_dashboard(db: Session = Depends(get_db)):
|
||||
"""仪表板统计:总体、城区、城郊、乡镇在线率"""
|
||||
# 每台设备最新状态子查询
|
||||
latest_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
||||
)
|
||||
.group_by(DeviceStatusHistory.onu_device_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_status_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
DeviceStatusHistory.status
|
||||
)
|
||||
.join(
|
||||
latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
ONUDevice.region,
|
||||
ONUDevice.school_name,
|
||||
func.count().label("total"),
|
||||
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
||||
)
|
||||
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||
.group_by(ONUDevice.region, ONUDevice.school_name)
|
||||
.order_by(ONUDevice.region, ONUDevice.school_name)
|
||||
.all()
|
||||
)
|
||||
|
||||
overall = {"total": 0, "online": 0, "offline": 0}
|
||||
urban_schools, suburban_schools = [], []
|
||||
rural_towns = {}
|
||||
|
||||
for row in rows:
|
||||
region = row.region or ""
|
||||
total = int(row.total or 0)
|
||||
online = int(row.online or 0)
|
||||
offline = int(row.offline or 0)
|
||||
overall["total"] += total
|
||||
overall["online"] += online
|
||||
overall["offline"] += offline
|
||||
|
||||
school_stat = {"name": row.school_name or "未知", "total": total, "online": online, "offline": offline}
|
||||
|
||||
if region == "城区":
|
||||
urban_schools.append(school_stat)
|
||||
elif region == "城郊":
|
||||
suburban_schools.append(school_stat)
|
||||
else:
|
||||
if region not in rural_towns:
|
||||
rural_towns[region] = {"region": region, "total": 0, "online": 0, "offline": 0, "schools": []}
|
||||
rural_towns[region]["total"] += total
|
||||
rural_towns[region]["online"] += online
|
||||
rural_towns[region]["offline"] += offline
|
||||
rural_towns[region]["schools"].append(school_stat)
|
||||
|
||||
def sort_by_rate(items):
|
||||
return sorted(items, key=lambda x: x["online"] / x["total"] if x["total"] > 0 else 0)
|
||||
|
||||
def agg(items):
|
||||
return {"total": sum(s["total"] for s in items), "online": sum(s["online"] for s in items), "offline": sum(s["offline"] for s in items)}
|
||||
|
||||
rural_list = sort_by_rate(list(rural_towns.values()))
|
||||
|
||||
return {
|
||||
"overall": overall,
|
||||
"urban": {**agg(urban_schools), "schools": sort_by_rate(urban_schools)},
|
||||
"suburban": {**agg(suburban_schools), "schools": sort_by_rate(suburban_schools)},
|
||||
"rural": {**agg(list(rural_towns.values())), "towns": rural_list},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def get_summary(db: Session = Depends(get_db)):
|
||||
"""获取统计摘要"""
|
||||
total = db.query(ONUDevice).count()
|
||||
latest_status = db.query(
|
||||
DeviceStatusHistory.status,
|
||||
func.count(DeviceStatusHistory.id)
|
||||
).group_by(DeviceStatusHistory.status).all()
|
||||
status_dict = dict(latest_status)
|
||||
return {"total": total, "online": status_dict.get('online', 0), "offline": status_dict.get('offline', 0)}
|
||||
|
||||
|
||||
@router.get("/trend")
|
||||
def get_trend(days: int = 7, db: Session = Depends(get_db)):
|
||||
"""获取状态趋势数据"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
history = db.query(
|
||||
func.date(DeviceStatusHistory.checked_at).label('date'),
|
||||
func.sum(func.case((DeviceStatusHistory.status == 'online', 1), else_=0)).label('online'),
|
||||
func.sum(func.case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label('offline')
|
||||
).filter(DeviceStatusHistory.checked_at >= start_date).group_by(
|
||||
func.date(DeviceStatusHistory.checked_at)
|
||||
).all()
|
||||
return [{"date": str(h.date), "online": h.online, "offline": h.offline} for h in history]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Casdoor 认证配置"""
|
||||
from casdoor import CasdoorSDK
|
||||
from app.core.config import settings
|
||||
|
||||
casdoor_sdk = CasdoorSDK(
|
||||
endpoint=settings.CASDOOR_ENDPOINT,
|
||||
client_id=settings.CASDOOR_CLIENT_ID,
|
||||
client_secret=settings.CASDOOR_CLIENT_SECRET,
|
||||
certificate=settings.casdoor_cert_content,
|
||||
org_name=settings.CASDOOR_ORG_NAME,
|
||||
application_name=settings.CASDOOR_APP_NAME,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Celery 配置"""
|
||||
from celery import Celery
|
||||
from app.core.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
"h3c_onu_ms",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer='json',
|
||||
result_serializer='json',
|
||||
accept_content=['json'],
|
||||
timezone='UTC',
|
||||
enable_utc=True,
|
||||
beat_schedule={
|
||||
'check-devices-every-30-minutes': {
|
||||
'task': 'app.tasks.check_tasks.check_all_devices',
|
||||
'schedule': settings.CHECK_INTERVAL,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""应用配置"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# 项目根目录(config.py 位于 backend/app/core/,parent.parent.parent 即 backend/)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "H3C-ONU-MS"
|
||||
DEBUG: bool = False
|
||||
SECRET_KEY: str
|
||||
|
||||
DATABASE_URL: str
|
||||
REDIS_URL: str
|
||||
|
||||
CASDOOR_ENDPOINT: str
|
||||
CASDOOR_CLIENT_ID: str
|
||||
CASDOOR_CLIENT_SECRET: str
|
||||
CASDOOR_ORG_NAME: str
|
||||
CASDOOR_APP_NAME: str
|
||||
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
|
||||
CASDOOR_REDIRECT_URL: str = "http://localhost:5173/callback"
|
||||
|
||||
SSH_TIMEOUT: int = 30
|
||||
CHECK_INTERVAL: int = 1800
|
||||
MANUAL_COOLDOWN: int = 300
|
||||
|
||||
class Config:
|
||||
env_file = str(PROJECT_ROOT / ".env")
|
||||
|
||||
@property
|
||||
def casdoor_cert_content(self) -> str:
|
||||
"""读取证书文件内容或直接返回证书字符串"""
|
||||
cert = self.CASDOOR_CERTIFICATE
|
||||
if not cert:
|
||||
return ""
|
||||
cert_path = Path(cert)
|
||||
if cert_path.is_absolute():
|
||||
path = cert_path
|
||||
else:
|
||||
# 相对路径基于项目根目录解析
|
||||
path = PROJECT_ROOT / cert
|
||||
if path.is_file():
|
||||
return path.read_text()
|
||||
return cert # 直接是 PEM 内容
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""数据库配置"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""JWT 安全配置"""
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
from app.core.config import settings
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24小时
|
||||
|
||||
|
||||
def create_access_token(data: dict):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str):
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
@@ -0,0 +1,28 @@
|
||||
"""FastAPI 主应用"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(devices.router)
|
||||
app.include_router(check.router)
|
||||
app.include_router(import_data.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(olt.router)
|
||||
app.include_router(provision.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""权限检查中间件"""
|
||||
from fastapi import HTTPException, Depends
|
||||
from app.core.security import verify_token
|
||||
|
||||
ROLE_PERMISSIONS = {
|
||||
'admin': ['*'],
|
||||
'area_admin': ['device.view', 'device.check'],
|
||||
'school_admin': ['device.view'],
|
||||
'user': ['device.view']
|
||||
}
|
||||
|
||||
|
||||
def check_permission(required_permission: str):
|
||||
def permission_checker(token: str):
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
|
||||
role = payload.get('role', 'user')
|
||||
permissions = ROLE_PERMISSIONS.get(role, [])
|
||||
|
||||
if '*' in permissions or required_permission in permissions:
|
||||
return payload
|
||||
|
||||
raise HTTPException(status_code=403, detail="权限不足")
|
||||
|
||||
return permission_checker
|
||||
@@ -0,0 +1,82 @@
|
||||
"""设备数据模型"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class OLTDevice(Base):
|
||||
__tablename__ = "olt_devices"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
ip_address = Column(String(45), nullable=False)
|
||||
username = Column(String(100), nullable=False)
|
||||
password = Column(Text, nullable=False)
|
||||
slot_command = Column(String(50), nullable=False)
|
||||
location = Column(String(200))
|
||||
description = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ONUDevice(Base):
|
||||
__tablename__ = "onu_devices"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
mac_address = Column(String(17), nullable=False, index=True)
|
||||
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"))
|
||||
slot_number = Column(Integer)
|
||||
port_number = Column(Integer)
|
||||
port_id = Column(String(20)) # 完整端口标识,如 "1/0/2:4"
|
||||
# OLT 返回的额外信息
|
||||
loid = Column(String(50)) # LOID
|
||||
model = Column(String(100)) # 设备型号
|
||||
distance_m = Column(Integer) # 距离(米)
|
||||
region = Column(String(100), index=True)
|
||||
school_name = Column(String(200), index=True)
|
||||
building = Column(String(100))
|
||||
place_type = Column(String(50)) # 场所类型
|
||||
room_number = Column(String(50))
|
||||
notes = Column(Text) # 备注
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# 关联状态历史
|
||||
status_history = relationship("DeviceStatusHistory", back_populates="device", lazy="selectin")
|
||||
|
||||
|
||||
class DeviceStatusHistory(Base):
|
||||
__tablename__ = "device_status_history"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"))
|
||||
status = Column(String(20), nullable=False) # online, offline
|
||||
distance_m = Column(Integer) # 距离(米)
|
||||
checked_at = Column(TIMESTAMP, nullable=False)
|
||||
response_data = Column(Text)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
device = relationship("ONUDevice", back_populates="status_history")
|
||||
|
||||
|
||||
class DuplicateMac(Base):
|
||||
"""重复 MAC 地址记录(同一 MAC 出现在多个端口)"""
|
||||
__tablename__ = "duplicate_macs"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
|
||||
mac_address = Column(String(17), nullable=False, index=True)
|
||||
# 所有出现的端口列表,JSON 格式: [{"port_id": "1/0/1:1", "status": "online"}, ...]
|
||||
ports = Column(JSON, nullable=False)
|
||||
first_seen_at = Column(TIMESTAMP, server_default=func.now())
|
||||
last_seen_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class NewDevice(Base):
|
||||
"""新发现设备(OLT 扫描到但尚未补全信息的设备)"""
|
||||
__tablename__ = "new_devices"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, unique=True)
|
||||
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
|
||||
discovered_at = Column(TIMESTAMP, server_default=func.now())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""权限数据模型"""
|
||||
from sqlalchemy import Column, BigInteger, String, Text, ForeignKey, Table
|
||||
from app.core.database import Base
|
||||
|
||||
role_permissions = Table(
|
||||
'role_permissions',
|
||||
Base.metadata,
|
||||
Column('role', String(50), primary_key=True),
|
||||
Column('permission_id', BigInteger, ForeignKey('permissions.id'), primary_key=True)
|
||||
)
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(BigInteger, primary_key=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
code = Column(String(50), unique=True, nullable=False)
|
||||
module = Column(String(50))
|
||||
description = Column(Text)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""用户数据模型"""
|
||||
from sqlalchemy import Column, BigInteger, String, Boolean, TIMESTAMP
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
casdoor_id = Column(String(100), unique=True, nullable=False)
|
||||
username = Column(String(100), nullable=False)
|
||||
email = Column(String(255))
|
||||
role = Column(String(50), default="user")
|
||||
assigned_area = Column(String(100))
|
||||
assigned_school = Column(String(200))
|
||||
is_active = Column(Boolean, default=True)
|
||||
last_login = Column(TIMESTAMP)
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
@@ -0,0 +1,20 @@
|
||||
"""认证相关 Schema"""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str]
|
||||
role: str
|
||||
assigned_area: Optional[str]
|
||||
assigned_school: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,36 @@
|
||||
"""设备相关 Schema"""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ONUDeviceBase(BaseModel):
|
||||
mac_address: str
|
||||
olt_id: Optional[int] = None
|
||||
region: Optional[str] = None
|
||||
school_name: Optional[str] = None
|
||||
building: Optional[str] = None
|
||||
place_type: Optional[str] = None # 场所类型
|
||||
room_number: Optional[str] = None
|
||||
notes: Optional[str] = None # 备注
|
||||
|
||||
|
||||
class ONUDeviceResponse(ONUDeviceBase):
|
||||
id: int
|
||||
status: Optional[str] = None
|
||||
distance_m: Optional[int] = None
|
||||
slot_number: Optional[int] = None
|
||||
port_number: Optional[int] = None
|
||||
port_id: Optional[str] = None # 完整端口标识,如 "1/0/2:4"
|
||||
model: Optional[str] = None
|
||||
olt_location: Optional[str] = None # OLT 安装位置
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DeviceListResponse(BaseModel):
|
||||
total: int
|
||||
items: list[ONUDeviceResponse]
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""设备状态检查服务"""
|
||||
from typing import List, Dict, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.services.ssh_service import SSHService
|
||||
from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, DuplicateMac, NewDevice
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
|
||||
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
|
||||
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
|
||||
if not distance_str:
|
||||
return None
|
||||
m = re.search(r'\d+', distance_str)
|
||||
return int(m.group()) if m else None
|
||||
|
||||
|
||||
class CheckService:
|
||||
"""设备状态检查服务"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def update_status_only(self, olt_id: int) -> Dict:
|
||||
"""扫描 OLT,仅更新已有设备的在线状态和距离(按全局 MAC 匹配)。
|
||||
不修改 olt_id/端口等字段,不标记 unknown,不入库新设备。
|
||||
"""
|
||||
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(olt.slot_command)
|
||||
onu_info_dict, _ = ssh.parse_onu_info(output)
|
||||
|
||||
checked_at = datetime.utcnow()
|
||||
online_count = 0
|
||||
offline_count = 0
|
||||
|
||||
# 全局 MAC → id 索引,只取需要的字段
|
||||
existing = {
|
||||
row.mac_address.lower(): row.id
|
||||
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
|
||||
}
|
||||
|
||||
for mac, onu_info in onu_info_dict.items():
|
||||
onu_id = existing.get(mac)
|
||||
if onu_id is None:
|
||||
continue # 不在库中,跳过
|
||||
|
||||
status = onu_info.status
|
||||
distance_m = parse_distance(onu_info.distance_str)
|
||||
if status == 'online':
|
||||
online_count += 1
|
||||
else:
|
||||
offline_count += 1
|
||||
|
||||
self.db.add(DeviceStatusHistory(
|
||||
onu_device_id=onu_id,
|
||||
status=status,
|
||||
distance_m=distance_m,
|
||||
checked_at=checked_at,
|
||||
response_data=None,
|
||||
))
|
||||
|
||||
self.db.commit()
|
||||
return {
|
||||
"online": online_count,
|
||||
"offline": offline_count,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
def check_single_device(self, device_id: int) -> Dict:
|
||||
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
|
||||
命令格式: display onu slot {slot} | include {mac}
|
||||
"""
|
||||
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise Exception("设备不存在")
|
||||
if not device.olt_id:
|
||||
raise Exception("该设备未关联 OLT,无法查询")
|
||||
|
||||
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
|
||||
if not olt:
|
||||
raise Exception("关联的 OLT 不存在")
|
||||
|
||||
mac = device.mac_address.lower()
|
||||
base_cmd = olt.slot_command # e.g. "display onu slot"
|
||||
if device.slot_number is not None:
|
||||
cmd = f"{base_cmd} {device.slot_number} | include {mac}"
|
||||
else:
|
||||
cmd = f"{base_cmd} | include {mac}"
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(cmd)
|
||||
|
||||
# 从输出中找到包含该 MAC 的行并解析
|
||||
info = None
|
||||
for line in output.splitlines():
|
||||
if mac in line.lower():
|
||||
info = ssh._parse_device_line(line, device.slot_number)
|
||||
if info:
|
||||
break
|
||||
|
||||
if info is None:
|
||||
# 未找到该 MAC,视为离线
|
||||
status = 'offline'
|
||||
distance_m = None
|
||||
else:
|
||||
status = info.status
|
||||
distance_m = parse_distance(info.distance_str)
|
||||
|
||||
self.db.add(DeviceStatusHistory(
|
||||
onu_device_id=device.id,
|
||||
status=status,
|
||||
distance_m=distance_m,
|
||||
checked_at=datetime.utcnow(),
|
||||
response_data=None,
|
||||
))
|
||||
self.db.commit()
|
||||
return {"status": status, "distance_m": distance_m}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
def scan_and_discover(self, olt_id: int) -> Dict:
|
||||
"""扫描 OLT,更新已有设备状态,并将新发现的在线设备入库。
|
||||
不标记 unknown,不修改已有设备的 olt_id/端口以外的字段。
|
||||
"""
|
||||
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(olt.slot_command)
|
||||
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||
|
||||
checked_at = datetime.utcnow()
|
||||
online_count = 0
|
||||
offline_count = 0
|
||||
new_count = 0
|
||||
|
||||
existing = {
|
||||
row.mac_address.lower(): row.id
|
||||
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
|
||||
}
|
||||
|
||||
for mac, onu_info in onu_info_dict.items():
|
||||
onu_id = existing.get(mac)
|
||||
|
||||
if onu_id is None:
|
||||
# 新设备:只入库在线的
|
||||
if onu_info.status != 'online':
|
||||
continue
|
||||
onu = ONUDevice(
|
||||
mac_address=mac,
|
||||
olt_id=olt_id,
|
||||
slot_number=onu_info.slot_number,
|
||||
port_number=onu_info.port_number,
|
||||
port_id=onu_info.port_id,
|
||||
distance_m=parse_distance(onu_info.distance_str),
|
||||
loid=onu_info.loid,
|
||||
model=onu_info.model,
|
||||
)
|
||||
self.db.add(onu)
|
||||
self.db.flush()
|
||||
self.db.add(NewDevice(onu_device_id=onu.id, olt_id=olt_id))
|
||||
onu_id = onu.id
|
||||
new_count += 1
|
||||
|
||||
status = onu_info.status
|
||||
distance_m = parse_distance(onu_info.distance_str)
|
||||
if status == 'online':
|
||||
online_count += 1
|
||||
else:
|
||||
offline_count += 1
|
||||
|
||||
self.db.add(DeviceStatusHistory(
|
||||
onu_device_id=onu_id,
|
||||
status=status,
|
||||
distance_m=distance_m,
|
||||
checked_at=checked_at,
|
||||
response_data=None,
|
||||
))
|
||||
|
||||
self._save_duplicate_macs(olt_id, duplicate_dict)
|
||||
self.db.commit()
|
||||
return {
|
||||
"online": online_count,
|
||||
"offline": offline_count,
|
||||
"new_discovered": new_count,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
async def scan_olt(self, olt_id: int) -> Dict:
|
||||
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
|
||||
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(olt.slot_command)
|
||||
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||
|
||||
# 对比全局 MAC
|
||||
existing_macs = {
|
||||
onu.mac_address.lower()
|
||||
for onu in self.db.query(ONUDevice.mac_address).all()
|
||||
}
|
||||
|
||||
devices = []
|
||||
for mac, info in onu_info_dict.items():
|
||||
devices.append({
|
||||
"mac_address": mac,
|
||||
"status": info.status,
|
||||
"distance_m": info.distance_str,
|
||||
"slot_number": info.slot_number,
|
||||
"port_number": info.port_number,
|
||||
"port_id": info.port_id,
|
||||
"loid": info.loid,
|
||||
"model": info.model,
|
||||
"is_new": mac not in existing_macs,
|
||||
})
|
||||
|
||||
duplicates = []
|
||||
for mac, records in duplicate_dict.items():
|
||||
duplicates.append({
|
||||
"mac_address": mac,
|
||||
"ports": [{"port_id": r.port_id, "status": r.status} for r in records],
|
||||
})
|
||||
|
||||
return {
|
||||
"olt_id": olt_id,
|
||||
"olt_ip": olt.ip_address,
|
||||
"total": len(devices),
|
||||
"new": sum(1 for d in devices if d["is_new"]),
|
||||
"devices": devices,
|
||||
"duplicates": duplicates,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
|
||||
for mac, records in duplicate_dict.items():
|
||||
ports = [{"port_id": r.port_id, "status": r.status} for r in records]
|
||||
existing = self.db.query(DuplicateMac).filter(
|
||||
DuplicateMac.olt_id == olt_id,
|
||||
DuplicateMac.mac_address == mac
|
||||
).first()
|
||||
if existing:
|
||||
existing.ports = ports
|
||||
existing.last_seen_at = datetime.utcnow()
|
||||
else:
|
||||
self.db.add(DuplicateMac(olt_id=olt_id, mac_address=mac, ports=ports))
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Excel 导入服务"""
|
||||
import pandas as pd
|
||||
from typing import List, Dict
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.device import ONUDevice
|
||||
import math
|
||||
|
||||
# 定义字段映射:Excel列名 -> 数据库字段名
|
||||
FIELD_MAPPING = {
|
||||
'mac_address': 'mac_address',
|
||||
'region': 'region',
|
||||
'school_name': 'school_name',
|
||||
'building': 'building',
|
||||
'place_type': 'place_type',
|
||||
'room_number': 'room_number',
|
||||
'notes': 'notes',
|
||||
}
|
||||
|
||||
|
||||
def clean_value(value) -> str:
|
||||
"""清理单元格值,处理NaN和None"""
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, float) and math.isnan(value):
|
||||
return ''
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
class ImportService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def parse_excel(self, file_path: str) -> List[Dict]:
|
||||
"""解析 Excel 文件"""
|
||||
df = pd.read_excel(file_path)
|
||||
# 标准化列名(去除空格,转小写)
|
||||
df.columns = [col.strip().lower() for col in df.columns]
|
||||
# 转换为记录列表
|
||||
records = []
|
||||
for _, row in df.iterrows():
|
||||
record = {}
|
||||
for col_name, db_field in FIELD_MAPPING.items():
|
||||
if col_name in row:
|
||||
record[db_field] = clean_value(row[col_name])
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
def validate_data(self, records: List[Dict]) -> Dict:
|
||||
"""验证数据"""
|
||||
valid = []
|
||||
invalid = []
|
||||
|
||||
for idx, record in enumerate(records):
|
||||
mac = record.get('mac_address', '').strip()
|
||||
if not mac:
|
||||
invalid.append({'record': record, 'error': f'第{idx+2}行: MAC地址缺失'})
|
||||
continue
|
||||
|
||||
# 标准化MAC地址:统一使用横杠分隔小写格式
|
||||
# 支持格式:AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF, AABBCCDDEEFF, aa:bb:cc:dd:ee:ff
|
||||
mac_clean = mac.upper().replace(':', '-')
|
||||
|
||||
# 验证基本格式:12个十六进制字符(可能有分隔符)
|
||||
hex_chars = mac_clean.replace('-', '')
|
||||
if len(hex_chars) != 12 or not all(c in '0123456789ABCDEF' for c in hex_chars):
|
||||
invalid.append({'record': record, 'error': f'第{idx+2}行: MAC地址格式错误 "{mac}"'})
|
||||
continue
|
||||
|
||||
# 转换为标准格式 34dc-99c8-56e0(小写4位分组)
|
||||
mac_formatted = '-'.join([hex_chars[i:i+4].lower() for i in range(0, 12, 4)])
|
||||
record['mac_address'] = mac_formatted
|
||||
valid.append(record)
|
||||
|
||||
return {'valid': valid, 'invalid': invalid}
|
||||
|
||||
def import_devices(self, records: List[Dict], olt_id: int = None) -> Dict:
|
||||
"""批量导入设备,存在则更新,不存在则新增"""
|
||||
success_count = 0
|
||||
skip_count = 0
|
||||
invalid_count = 0
|
||||
|
||||
for record in records:
|
||||
mac = record.get('mac_address', '')
|
||||
if not mac:
|
||||
skip_count += 1
|
||||
continue
|
||||
|
||||
# 查询是否已存在该 MAC 地址
|
||||
existing = self.db.query(ONUDevice).filter(ONUDevice.mac_address == mac).first()
|
||||
|
||||
if existing:
|
||||
# 更新现有记录
|
||||
existing.region = record.get('region', '')
|
||||
existing.school_name = record.get('school_name', '')
|
||||
existing.building = record.get('building') or None
|
||||
existing.place_type = record.get('place_type') or None
|
||||
existing.room_number = record.get('room_number') or None
|
||||
existing.notes = record.get('notes') or None
|
||||
success_count += 1
|
||||
else:
|
||||
# 新增记录
|
||||
device = ONUDevice(
|
||||
mac_address=mac,
|
||||
olt_id=olt_id,
|
||||
region=record.get('region', ''),
|
||||
school_name=record.get('school_name', ''),
|
||||
building=record.get('building') or None,
|
||||
place_type=record.get('place_type') or None,
|
||||
room_number=record.get('room_number') or None,
|
||||
notes=record.get('notes') or None
|
||||
)
|
||||
self.db.add(device)
|
||||
success_count += 1
|
||||
|
||||
self.db.commit()
|
||||
return {'success': success_count}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""业务下发服务"""
|
||||
from typing import Dict
|
||||
from sqlalchemy.orm import Session
|
||||
from app.services.ssh_service import SSHService
|
||||
from app.models.device import ONUDevice, OLTDevice
|
||||
import time
|
||||
|
||||
|
||||
class ProvisionService:
|
||||
"""业务下发服务"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def provision_device(self, device_id: int) -> Dict[str, any]:
|
||||
"""
|
||||
业务下发流程(参照下发业务流程.md):
|
||||
1. system-view
|
||||
2. interface Onu{port_id}
|
||||
3. uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
|
||||
4. port link-type trunk
|
||||
5. undo port trunk permit vlan 1
|
||||
6. port trunk permit vlan 2000 3000 to 3010 4094
|
||||
7. save force
|
||||
"""
|
||||
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
return {"success": False, "error": f"设备不存在: {device_id}"}
|
||||
|
||||
if not device.olt_id:
|
||||
return {"success": False, "error": "设备未关联 OLT,请先进行扫描"}
|
||||
|
||||
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
|
||||
if not olt:
|
||||
return {"success": False, "error": "关联的 OLT 设备不存在"}
|
||||
|
||||
if not device.port_id:
|
||||
return {"success": False, "error": "设备端口信息不完整,请先对 OLT 执行扫描"}
|
||||
|
||||
port_name = f"Onu{device.port_id}"
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
|
||||
try:
|
||||
ssh.connect()
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
|
||||
ssh.shell.send(cmd + "\n")
|
||||
buf = ""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if ssh.shell.recv_ready():
|
||||
buf += ssh.shell.recv(4096).decode("utf-8", errors="ignore")
|
||||
if expect in buf:
|
||||
return buf
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
return buf
|
||||
|
||||
# 进入系统视图
|
||||
out = send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
# 进入端口
|
||||
out = send_and_wait(f"interface {port_name}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"进入端口 {port_name} 失败")
|
||||
|
||||
# 配置 VLAN
|
||||
send_and_wait("uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010", "]")
|
||||
send_and_wait("port link-type trunk", "]")
|
||||
send_and_wait("undo port trunk permit vlan 1", "]")
|
||||
send_and_wait("port trunk permit vlan 2000 3000 to 3010 4094", "]")
|
||||
|
||||
# 保存配置(等待 "successfully" 出现)
|
||||
save_out = send_and_wait("save force", "successfully", timeout=30)
|
||||
if "successfully" not in save_out:
|
||||
raise Exception("save force 未确认成功,请检查 OLT 日志")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"device_id": device_id,
|
||||
"mac_address": device.mac_address,
|
||||
"olt_ip": olt.ip_address,
|
||||
"port": port_name,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"device_id": device_id,
|
||||
"mac_address": device.mac_address,
|
||||
"error": str(e),
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
def batch_provision(self, device_ids: list) -> Dict[str, any]:
|
||||
results = []
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
for device_id in device_ids:
|
||||
result = self.provision_device(device_id)
|
||||
results.append(result)
|
||||
if result.get("success"):
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
return {
|
||||
"total": len(device_ids),
|
||||
"success": success_count,
|
||||
"failed": fail_count,
|
||||
"results": results,
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"""SSH 连接服务"""
|
||||
import paramiko
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ONUInfo:
|
||||
"""ONU 设备完整信息"""
|
||||
mac_address: str
|
||||
status: str # online/offline
|
||||
distance_m: Optional[int] = None # 距离(米)
|
||||
distance_str: Optional[str] = None # 距离原始字符串,如 "<1000"
|
||||
slot_number: Optional[int] = None # 插槽号
|
||||
port_number: Optional[int] = None # 端口号
|
||||
port_id: Optional[str] = None # 完整端口标识,如 "1/0/1:1"
|
||||
loid: Optional[str] = None # LOID
|
||||
model: Optional[str] = None # 设备型号
|
||||
|
||||
|
||||
class SSHService:
|
||||
"""SSH 连接和命令执行服务"""
|
||||
|
||||
def __init__(self, host: str, username: str, password: str, port: int = 22):
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.port = port
|
||||
self.client: Optional[paramiko.SSHClient] = None
|
||||
self.shell = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""建立 SSH 连接,等待初始 banner 输出完毕"""
|
||||
try:
|
||||
self.client = paramiko.SSHClient()
|
||||
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
self.client.connect(
|
||||
hostname=self.host,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
timeout=30
|
||||
)
|
||||
self.shell = self.client.invoke_shell(width=200, height=50)
|
||||
# 等待登录 banner 输出完毕,直到出现命令提示符 ">"
|
||||
deadline = time.time() + 10
|
||||
buf = ""
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if ">" in buf:
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
return True
|
||||
except Exception as e:
|
||||
raise Exception(f"SSH 连接失败: {str(e)}")
|
||||
|
||||
def execute_command(self, command: str) -> str:
|
||||
"""执行命令并处理 More 分页,等待命令提示符出现后返回"""
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
self.shell.send(command + "\n")
|
||||
output = ""
|
||||
# 等待命令回显出现,再开始收集输出
|
||||
time.sleep(0.5)
|
||||
|
||||
deadline = time.time() + 60
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
chunk = self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
output += chunk
|
||||
if "---- More ----" in chunk:
|
||||
self.shell.send(" ")
|
||||
time.sleep(0.3)
|
||||
elif ">" in chunk:
|
||||
# 命令提示符出现,说明输出完毕
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
|
||||
return output
|
||||
|
||||
def clear_onu_port(self, port_id: str) -> bool:
|
||||
"""清除指定端口的 ONU 配置(恢复默认)
|
||||
流程: system-view -> interface Onu{port_id} -> default -> Y
|
||||
"""
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
|
||||
self.shell.send(cmd + "\n")
|
||||
buf = ""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if expect in buf:
|
||||
return buf
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
return buf
|
||||
|
||||
# 进入系统视图
|
||||
out = send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
# 进入端口
|
||||
out = send_and_wait(f"interface Onu{port_id}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"进入端口 Onu{port_id} 失败")
|
||||
|
||||
# 执行 default,等待确认提示
|
||||
self.shell.send("default\n")
|
||||
buf = ""
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if "[Y/N]" in buf or "[y/n]" in buf:
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
|
||||
if "[Y/N]" not in buf and "[y/n]" not in buf:
|
||||
raise Exception("未收到确认提示")
|
||||
|
||||
# 确认
|
||||
self.shell.send("Y\n")
|
||||
time.sleep(1)
|
||||
# 排空缓冲区
|
||||
if self.shell.recv_ready():
|
||||
self.shell.recv(4096)
|
||||
|
||||
# 退出到用户视图
|
||||
send_and_wait("quit", "]", timeout=5)
|
||||
send_and_wait("quit", ">", timeout=5)
|
||||
|
||||
return True
|
||||
|
||||
def detect_loopback(self) -> dict:
|
||||
"""执行环路检测,返回 {has_loop: bool, interfaces: [str]}"""
|
||||
output = self.execute_command("display loopback-detection")
|
||||
has_loop = "Loop is detected on following interfaces" in output
|
||||
interfaces = []
|
||||
if has_loop:
|
||||
for line in output.splitlines():
|
||||
m = re.match(r'\s+(Onu\S+)\s+', line)
|
||||
if m:
|
||||
interfaces.append(m.group(1))
|
||||
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
|
||||
|
||||
def parse_onu_status(self, output: str) -> Dict[str, str]:
|
||||
"""解析 ONU 状态输出"""
|
||||
devices = {}
|
||||
lines = output.split('\n')
|
||||
|
||||
for line in lines:
|
||||
# 匹配包含 MAC 地址的行
|
||||
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
|
||||
if mac_match:
|
||||
mac = mac_match.group(1).lower()
|
||||
# 提取状态字段
|
||||
if 'Up' in line:
|
||||
devices[mac] = 'online'
|
||||
elif 'Offline' in line:
|
||||
devices[mac] = 'offline'
|
||||
|
||||
return devices
|
||||
|
||||
def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]:
|
||||
"""增强解析:提取完整 ONU 信息
|
||||
返回: (unique_devices, duplicate_devices)
|
||||
- unique_devices: MAC -> ONUInfo(每个 MAC 只保留最新端口)
|
||||
- duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC)
|
||||
"""
|
||||
all_records: Dict[str, List[ONUInfo]] = {}
|
||||
lines = output.split('\n')
|
||||
|
||||
current_slot = None
|
||||
|
||||
for line in lines:
|
||||
# 检测新的插槽区域: Olt1/0/1
|
||||
slot_match = re.search(r'Olt(\d+)/(\d+)/(\d+)', line)
|
||||
if slot_match:
|
||||
current_slot = int(slot_match.group(3))
|
||||
continue
|
||||
|
||||
# 跳过表头行和空行
|
||||
if 'MAC' in line and 'LOID' in line:
|
||||
continue
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
# 解析设备行
|
||||
device = self._parse_device_line(line, current_slot)
|
||||
if device:
|
||||
if device.mac_address not in all_records:
|
||||
all_records[device.mac_address] = []
|
||||
all_records[device.mac_address].append(device)
|
||||
|
||||
unique_devices: Dict[str, ONUInfo] = {}
|
||||
duplicate_devices: Dict[str, List[ONUInfo]] = {}
|
||||
|
||||
for mac, records in all_records.items():
|
||||
if len(records) == 1:
|
||||
unique_devices[mac] = records[0]
|
||||
else:
|
||||
duplicate_devices[mac] = records
|
||||
# unique 中保留在线的,若都离线则保留最后一条
|
||||
online = [r for r in records if r.status == 'online']
|
||||
unique_devices[mac] = online[0] if online else records[-1]
|
||||
|
||||
return unique_devices, duplicate_devices
|
||||
|
||||
def _parse_device_line(self, line: str, slot: Optional[int]) -> Optional[ONUInfo]:
|
||||
"""解析单行设备信息"""
|
||||
# 匹配 MAC 地址
|
||||
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
|
||||
if not mac_match:
|
||||
return None
|
||||
|
||||
mac = mac_match.group(1).lower()
|
||||
|
||||
# 提取状态
|
||||
status = 'offline'
|
||||
if 'Up' in line:
|
||||
status = 'online'
|
||||
|
||||
# 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1"
|
||||
slot_num, port_num, port_id = None, None, None
|
||||
port_match = re.search(r'Onu(\d+)/(\d+)/(\d+):(\d+)', line)
|
||||
if port_match:
|
||||
slot_num = int(port_match.group(3)) # 第三段数字为槽位
|
||||
port_num = int(port_match.group(4)) # 冒号后为端口号
|
||||
port_id = f"{port_match.group(1)}/{port_match.group(2)}/{port_match.group(3)}:{port_match.group(4)}"
|
||||
|
||||
# 提取距离 - Port 列前的字段,如 "<1000" 或 "N/A"
|
||||
distance_str = None
|
||||
dist_match = re.search(r'(\S+)\s+Onu\d+/\d+/\d+:\d+', line)
|
||||
if dist_match:
|
||||
val = dist_match.group(1)
|
||||
if val != 'N/A':
|
||||
distance_str = val
|
||||
|
||||
# 提取 LOID - MAC 后第一个非空字段
|
||||
loid = None
|
||||
loid_match = re.search(r'[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}\s+(\S+)', line, re.IGNORECASE)
|
||||
if loid_match:
|
||||
loid_val = loid_match.group(1)
|
||||
if loid_val != 'N/A' and loid_val.isdigit():
|
||||
loid = loid_val
|
||||
|
||||
# 提取设备型号 - Port 列之后的第一个字段,如 "WA6520H-EGPON/A"
|
||||
model = None
|
||||
model_match = re.search(r'Onu\d+/\d+/\d+:\d+\s+(\S+)', line)
|
||||
if model_match:
|
||||
potential_model = model_match.group(1)
|
||||
if potential_model != 'N/A':
|
||||
model = potential_model
|
||||
|
||||
return ONUInfo(
|
||||
mac_address=mac,
|
||||
status=status,
|
||||
distance_m=None,
|
||||
distance_str=distance_str,
|
||||
slot_number=slot_num or slot,
|
||||
port_number=port_num,
|
||||
port_id=port_id,
|
||||
loid=loid,
|
||||
model=model
|
||||
)
|
||||
|
||||
def close(self):
|
||||
"""关闭 SSH 连接"""
|
||||
if self.client:
|
||||
self.client.close()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Celery 任务模块"""
|
||||
from app.tasks.check_tasks import check_all_devices # noqa: F401
|
||||
|
||||
__all__ = ['check_all_devices']
|
||||
@@ -0,0 +1,72 @@
|
||||
"""状态检查任务"""
|
||||
import traceback
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.check_service import CheckService
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def check_all_devices(self):
|
||||
"""检查所有设备状态"""
|
||||
db = SessionLocal()
|
||||
self.update_state(state='PROGRESS', meta={'current': 0, 'total': 0, 'status': '获取OLT列表...'})
|
||||
try:
|
||||
service = CheckService(db)
|
||||
from app.models.device import OLTDevice
|
||||
olts = db.query(OLTDevice).all()
|
||||
|
||||
total = len(olts)
|
||||
self.update_state(state='PROGRESS', meta={'current': 0, 'total': total, 'status': f'准备检查 {total} 个OLT...'})
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
total_online = 0
|
||||
total_offline = 0
|
||||
|
||||
for idx, olt in enumerate(olts):
|
||||
self.update_state(state='PROGRESS', meta={
|
||||
'current': idx, 'total': total,
|
||||
'status': f'检查 OLT: {olt.location or olt.ip_address}...'
|
||||
})
|
||||
try:
|
||||
result = service.update_status_only(olt.id)
|
||||
total_online += result.get('online', 0)
|
||||
total_offline += result.get('offline', 0)
|
||||
results.append({
|
||||
'olt_id': olt.id,
|
||||
'olt_name': olt.location or olt.ip_address,
|
||||
'online': result.get('online', 0),
|
||||
'offline': result.get('offline', 0),
|
||||
'success': True
|
||||
})
|
||||
except Exception as e:
|
||||
errors.append({
|
||||
'olt_id': olt.id,
|
||||
'olt_name': olt.location or olt.ip_address,
|
||||
'error': str(e)
|
||||
})
|
||||
results.append({
|
||||
'olt_id': olt.id,
|
||||
'olt_name': olt.location or olt.ip_address,
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'total_olts': total,
|
||||
'total_online': total_online,
|
||||
'total_offline': total_offline,
|
||||
'results': results,
|
||||
'errors': errors
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'traceback': traceback.format_exc()
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Celery Worker 入口模块"""
|
||||
from app.core.celery_app import celery_app
|
||||
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
|
||||
|
||||
__all__ = ['celery_app']
|
||||
@@ -0,0 +1,20 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
sqlalchemy==2.0.25
|
||||
alembic==1.13.1
|
||||
psycopg2-binary==2.9.9
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.6
|
||||
celery==5.3.6
|
||||
redis==5.0.1
|
||||
paramiko==3.4.0
|
||||
pandas==2.1.4
|
||||
openpyxl==3.1.2
|
||||
cryptography==42.0.0
|
||||
casdoor==1.18.0
|
||||
aiohttp>=3.9.0
|
||||
PyJWT>=2.8.0
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,11 @@
|
||||
"""数据库初始化脚本"""
|
||||
from app.core.database import engine, Base
|
||||
from app.models import device, user, permission
|
||||
|
||||
def init_db():
|
||||
"""创建所有表"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("数据库表创建完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
"""测试 ONU 状态解析"""
|
||||
import re
|
||||
|
||||
def parse_onu_status(output: str):
|
||||
"""解析 ONU 状态"""
|
||||
devices = {}
|
||||
lines = output.split('\n')
|
||||
|
||||
for line in lines:
|
||||
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
|
||||
if mac_match:
|
||||
mac = mac_match.group(1).lower()
|
||||
if 'Up' in line:
|
||||
devices[mac] = 'online'
|
||||
elif 'Offline' in line:
|
||||
devices[mac] = 'offline'
|
||||
|
||||
return devices
|
||||
|
||||
# 测试数据
|
||||
test_output = """
|
||||
1484-7790-5200 12 <1000 Onu1/0/1:1 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-7790-4e20 13 <1000 Onu1/0/1:2 WA6520H-EGPON/A 106/ Up N/A
|
||||
1484-7790-3900 N/A N/A Onu1/0/1:3 N/A N/A Offline N/A
|
||||
"""
|
||||
|
||||
result = parse_onu_status(test_output)
|
||||
print("解析结果:")
|
||||
for mac, status in result.items():
|
||||
print(f" {mac}: {status}")
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIE2TCCAsGgAwIBAgIDAeJAMA0GCSqGSIb3DQEBCwUAMCYxDjAMBgNVBAoTBWFk
|
||||
bWluMRQwEgYDVQQDDAtjZXJ0X3drMWVlZjAeFw0yNTEyMTAxMDAzMzVaFw00NTEy
|
||||
MTAxMDAzMzVaMCYxDjAMBgNVBAoTBWFkbWluMRQwEgYDVQQDDAtjZXJ0X3drMWVl
|
||||
ZjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK78ajnNnwnutdZ48l92
|
||||
hmqa2c8lP1IcpyB7CYVTKurQxSz5iQorYOVhR1UzluSpU8yiPPeFyTRD0pH+DzqG
|
||||
otc+Alvxnka5DfP1z/P0asogkALJXouRR+YtUY6i1oo5tKlDbIGJNO2aQcOOak9b
|
||||
YoqSRd9y/pq/bPjte7oww29hGQc03LgbNXmIb9n5NGznWCte1c88NUrTz9Dlpn2H
|
||||
r3ncOmHpqpzg5NtXughnHsF4YCF+pPIgWlC0C6MtKk0fuJysCY5wpuA620pGL4zO
|
||||
6QFv920MiWsdVWxcNo0aNQkXKGjEcy3LraXux2k/sH+E0e3GRXGhWYnkrK7i8kg+
|
||||
V+Nm1YFgyGbY7V3yZ0mXPxb+iMlIvz885ViGsRnKqlR0pN1v8NbmzXPu9EkAO3wi
|
||||
T/L4fxnETW1hd/ph+AdQ0jEpeyAMRjcl3kMjauOBqfU/THl7L6aUMXB3d05+JK0Y
|
||||
uTc1nrZ0Qjh/5EG5XyvRSuKNVxpCB1XcAlAaBTuE5art6jQkCJINvFoOjoHZB65K
|
||||
HJVvfMtMuVr7dLSbYOPHH6YJB2fUijNKoXnclAbxldX3fEALsC/SW7zMFF+3FThg
|
||||
Seq3OEpNdbkRlQp5sORHeTkWtMO9A60GHvTqDamIqppC4fk71zIRpmEPlfGsOno5
|
||||
gNhH8+NKH5Cvmfj4s7/S98D/AgMBAAGjEDAOMAwGA1UdEwEB/wQCMAAwDQYJKoZI
|
||||
hvcNAQELBQADggIBAJLW6zjDKZdkfq00r88lM1IxaxRWmUEPQjINOa0GZH/DyAtd
|
||||
fgYf35AN+NDEiIiDnJQTR3N8jbX1JySDClEOzv3wLXRiKhWcX8z4HOoieNmMu9lZ
|
||||
6pf1lEaMX8WNxB475nfOueEEyHegbsbfpmYM/aVAiSxaKb87if7uDnxJeGqK2Ba0
|
||||
c3LJnQ+H87gcAb84G78KXn/XwjRtwaf+Gy06EzycEnckEeN0vni0pRdw8coSudcX
|
||||
726Kb+kZ4961G/XxQx4fKX+E4lXkx1CdxPBAws/uOjfciLnuVdT4yp6URXJKRw+/
|
||||
R+flPtfvHXNDXB8BHj9ul4UJ4oSHGppx0BR80CmWWNDVdaoO+o0WieFOXG2U2PB+
|
||||
p2pleJzckTDuOh5wIe2GFj9WKZTIn4S7ZA6hS5j5ea4bOX5m6cxXVaS5FPHHfHcN
|
||||
FyUOEE4f61tv9omsFyl23YD/gmQzotFY+5YPS8DTb1soSzydKQODCq7SobyK8QFo
|
||||
3FnOw1cnKuWDZbqkZS6TYHFc3doNaH77bBxgSgIBPlJewd98eZZCv+m8/mCOXCbo
|
||||
+7HOgmOHJ1cOEtweCtgrCc2VNvV0w6RSwcZ+WmUAZMajvyEqikVVQzR2LtOOhOyr
|
||||
REkkNRAX3PX69GDQLM9QSgGLv0f6nA5uTX9SFj71Iik6YAuUsvIK6uPpn/2B
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,185 @@
|
||||
# H3C ONU设备管理系统 - 环境变量配置示例
|
||||
# 复制此文件为 .env 并修改相应配置
|
||||
|
||||
# ============================================
|
||||
# 应用基础配置
|
||||
# ============================================
|
||||
|
||||
# 应用环境 (development, production)
|
||||
APP_ENV=production
|
||||
|
||||
# 调试模式 (true/false)
|
||||
DEBUG=false
|
||||
|
||||
# 应用密钥 (使用 openssl rand -hex 32 生成)
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
|
||||
# 时区设置
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# ============================================
|
||||
# 数据库配置 (PostgreSQL)
|
||||
# ============================================
|
||||
|
||||
# PostgreSQL连接字符串
|
||||
# 格式: postgresql://username:password@host:port/database
|
||||
DATABASE_URL=postgresql://h3c_user:your_password@postgres-host:5432/h3c_onu_ms
|
||||
|
||||
# 数据库连接池配置
|
||||
DATABASE_POOL_SIZE=20
|
||||
DATABASE_MAX_OVERFLOW=40
|
||||
DATABASE_POOL_RECYCLE=3600
|
||||
|
||||
# ============================================
|
||||
# Redis配置
|
||||
# ============================================
|
||||
|
||||
# Redis连接URL (如果使用外部Redis,修改为外部地址)
|
||||
REDIS_URL=redis://:redispass@redis:6379/0
|
||||
|
||||
# Redis密码 (容器内Redis使用)
|
||||
REDIS_PASSWORD=redispass
|
||||
|
||||
# Redis连接池大小
|
||||
REDIS_POOL_SIZE=10
|
||||
|
||||
# ============================================
|
||||
# Casdoor认证配置
|
||||
# ============================================
|
||||
|
||||
# Casdoor服务地址
|
||||
CASDOOR_ENDPOINT=https://casdoor.example.com
|
||||
|
||||
# 应用Client ID
|
||||
CASDOOR_CLIENT_ID=your_casdoor_client_id
|
||||
|
||||
# 应用Client Secret
|
||||
CASDOOR_CLIENT_SECRET=your_casdoor_client_secret
|
||||
|
||||
# 应用Certificate
|
||||
CASDOOR_CERTIFICATE=your_casdoor_certificate
|
||||
|
||||
# 组织名称
|
||||
CASDOOR_ORG_NAME=your_organization
|
||||
|
||||
# 应用名称
|
||||
CASDOOR_APP_NAME=h3c-onu-ms
|
||||
|
||||
# ============================================
|
||||
# SSH连接配置
|
||||
# ============================================
|
||||
|
||||
# SSH连接超时时间(秒)
|
||||
SSH_TIMEOUT=30
|
||||
|
||||
# 最大并发连接数
|
||||
SSH_MAX_CONNECTIONS=10
|
||||
|
||||
# SSH重试次数
|
||||
SSH_RETRY_COUNT=3
|
||||
|
||||
# SSH连接保持时间(秒)
|
||||
SSH_KEEPALIVE=60
|
||||
|
||||
# ============================================
|
||||
# 任务调度配置
|
||||
# ============================================
|
||||
|
||||
# 自动检查间隔(秒) - 30分钟
|
||||
CHECK_INTERVAL=1800
|
||||
|
||||
# 手动刷新冷却时间(秒) - 5分钟
|
||||
MANUAL_COOLDOWN=300
|
||||
|
||||
# 历史记录保留天数
|
||||
HISTORY_RETENTION=90
|
||||
|
||||
# 批量检查设备数量
|
||||
BATCH_CHECK_SIZE=100
|
||||
|
||||
# ============================================
|
||||
# 前端配置
|
||||
# ============================================
|
||||
|
||||
# API基础URL (前端访问后端地址)
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# Casdoor前端配置
|
||||
VITE_CASDOOR_ENDPOINT=https://casdoor.example.com
|
||||
VITE_CASDOOR_CLIENT_ID=your_casdoor_client_id
|
||||
VITE_CASDOOR_ORG_NAME=your_organization
|
||||
VITE_CASDOOR_APP_NAME=h3c-onu-ms
|
||||
|
||||
# 应用标题
|
||||
VITE_APP_TITLE=H3C ONU设备管理系统
|
||||
|
||||
# ============================================
|
||||
# 日志配置
|
||||
# ============================================
|
||||
|
||||
# 日志级别 (DEBUG, INFO, WARNING, ERROR)
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# 日志文件路径
|
||||
LOG_FILE=/app/logs/app.log
|
||||
|
||||
# 日志轮转大小
|
||||
LOG_ROTATION=10MB
|
||||
|
||||
# 日志保留天数
|
||||
LOG_RETENTION=30
|
||||
|
||||
# ============================================
|
||||
# 邮件通知配置 (可选)
|
||||
# ============================================
|
||||
|
||||
# SMTP服务器
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=your_email@example.com
|
||||
# SMTP_PASSWORD=your_email_password
|
||||
# SMTP_FROM=noreply@example.com
|
||||
|
||||
# ============================================
|
||||
# 监控配置
|
||||
# ============================================
|
||||
|
||||
# 健康检查端口
|
||||
HEALTH_CHECK_PORT=8000
|
||||
|
||||
# 监控指标端口
|
||||
METRICS_PORT=8000
|
||||
|
||||
# ============================================
|
||||
# 安全配置
|
||||
# ============================================
|
||||
|
||||
# CORS允许的域名 (逗号分隔)
|
||||
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
|
||||
|
||||
# 速率限制配置
|
||||
RATE_LIMIT_PER_MINUTE=60
|
||||
RATE_LIMIT_PER_HOUR=1000
|
||||
|
||||
# ============================================
|
||||
# 备份配置
|
||||
# ============================================
|
||||
|
||||
# 数据库备份目录
|
||||
BACKUP_DIR=/app/backups
|
||||
|
||||
# 备份保留天数
|
||||
BACKUP_RETENTION=30
|
||||
|
||||
# 自动备份时间 (cron格式)
|
||||
BACKUP_SCHEDULE="0 2 * * *"
|
||||
|
||||
# ============================================
|
||||
# 开发环境覆盖配置
|
||||
# ============================================
|
||||
|
||||
# 如果是开发环境,取消注释以下配置
|
||||
# DEBUG=true
|
||||
# DATABASE_URL=postgresql://h3c_user:password@localhost:5432/h3c_onu_ms_dev
|
||||
# REDIS_URL=redis://localhost:6379/0
|
||||
# VITE_API_BASE_URL=http://localhost:8000
|
||||
@@ -0,0 +1,104 @@
|
||||
services:
|
||||
# 后端API服务
|
||||
backend:
|
||||
build:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: h3c-onu-ms-backend
|
||||
ports:
|
||||
- "8001:8000"
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT}
|
||||
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
|
||||
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
|
||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- DEBUG=${DEBUG:-false}
|
||||
volumes:
|
||||
- ../backend/logs:/app/logs
|
||||
- ../backend/static:/app/static
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Celery Worker - 处理异步任务
|
||||
celery-worker:
|
||||
build:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: h3c-onu-ms-celery-worker
|
||||
command: celery -A app.core.celery_app worker --loglevel=info --concurrency=4
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT}
|
||||
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
|
||||
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
|
||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
volumes:
|
||||
- ../backend/logs:/app/logs
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
# Celery Beat - 定时任务调度
|
||||
celery-beat:
|
||||
build:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: h3c-onu-ms-celery-beat
|
||||
command: celery -A app.core.celery_app beat --loglevel=info
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
- CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT}
|
||||
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
|
||||
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
|
||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
volumes:
|
||||
- ../backend/logs:/app/logs
|
||||
- ../backend/celerybeat-schedule:/app/celerybeat-schedule
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
# 前端应用
|
||||
frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: h3c-onu-ms-frontend
|
||||
ports:
|
||||
- "8081:80"
|
||||
environment:
|
||||
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8001}
|
||||
- VITE_CASDOOR_ENDPOINT=${VITE_CASDOOR_ENDPOINT}
|
||||
- VITE_CASDOOR_CLIENT_ID=${VITE_CASDOOR_CLIENT_ID}
|
||||
- VITE_CASDOOR_ORG_NAME=${VITE_CASDOOR_ORG_NAME}
|
||||
- VITE_CASDOOR_APP_NAME=${VITE_CASDOOR_APP_NAME}
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:80"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: h3c-onu-ms-network
|
||||
@@ -0,0 +1,165 @@
|
||||
# H3C ONU设备管理系统 - Nginx站点配置
|
||||
|
||||
# 前端静态文件服务
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 根目录指向前端构建文件
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip压缩
|
||||
gzip_static on;
|
||||
|
||||
# 静态文件缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# 前端路由支持 (Vue Router history模式)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# API代理到后端
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# 缓冲区设置
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
proxy_busy_buffers_size 8k;
|
||||
|
||||
# 禁用代理缓存
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# WebSocket支持
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# WebSocket超时设置
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# 静态文件代理 (后端静态文件)
|
||||
location /static/ {
|
||||
alias /usr/share/nginx/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# 健康检查端点
|
||||
location /health {
|
||||
proxy_pass http://backend:8000/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# 监控指标端点
|
||||
location /metrics {
|
||||
proxy_pass http://backend:8000/metrics;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# API文档
|
||||
location /docs {
|
||||
proxy_pass http://backend:8000/docs;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /redoc {
|
||||
proxy_pass http://backend:8000/redoc;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 错误页面
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS配置 (需要SSL证书)
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name your-domain.com;
|
||||
#
|
||||
# # SSL证书配置
|
||||
# ssl_certificate /etc/nginx/ssl/your-domain.com.crt;
|
||||
# ssl_certificate_key /etc/nginx/ssl/your-domain.com.key;
|
||||
#
|
||||
# # SSL优化配置
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
# ssl_prefer_server_ciphers off;
|
||||
# ssl_session_cache shared:SSL:10m;
|
||||
# ssl_session_timeout 10m;
|
||||
#
|
||||
# # HSTS头
|
||||
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
#
|
||||
# # 其他配置与HTTP相同
|
||||
# root /usr/share/nginx/html;
|
||||
# index index.html;
|
||||
#
|
||||
# location / {
|
||||
# try_files $uri $uri/ /index.html;
|
||||
# }
|
||||
#
|
||||
# location /api/ {
|
||||
# proxy_pass http://backend:8000;
|
||||
# # ... 其他代理配置
|
||||
# }
|
||||
#
|
||||
# # 强制HTTP重定向到HTTPS
|
||||
# # if ($scheme != "https") {
|
||||
# # return 301 https://$host$request_uri;
|
||||
# # }
|
||||
# }
|
||||
|
||||
# 重定向HTTP到HTTPS (如果启用HTTPS)
|
||||
# server {
|
||||
# listen 80;
|
||||
# server_name your-domain.com;
|
||||
# return 301 https://$server_name$request_uri;
|
||||
# }
|
||||
@@ -0,0 +1,56 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# 日志格式
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# 基础配置
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 100M;
|
||||
|
||||
# Gzip压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# 包含站点配置
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
Executable
+362
@@ -0,0 +1,362 @@
|
||||
#!/bin/bash
|
||||
|
||||
# H3C ONU设备管理系统 - 部署脚本
|
||||
# 使用方法: ./deploy.sh [环境] [操作]
|
||||
# 环境: dev, prod (默认: prod)
|
||||
# 操作: up, down, restart, logs, build (默认: up)
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 默认值
|
||||
ENV=${1:-prod}
|
||||
ACTION=${2:-up}
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
|
||||
# 根据环境选择配置文件
|
||||
if [ "$ENV" = "dev" ]; then
|
||||
COMPOSE_FILE="docker-compose.dev.yml"
|
||||
echo -e "${BLUE}使用开发环境配置${NC}"
|
||||
elif [ "$ENV" = "prod" ]; then
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
echo -e "${BLUE}使用生产环境配置${NC}"
|
||||
else
|
||||
echo -e "${RED}错误: 未知环境 '$ENV'${NC}"
|
||||
echo "可用环境: dev, prod"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查Docker Compose文件是否存在
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
echo -e "${RED}错误: Docker Compose文件 '$COMPOSE_FILE' 不存在${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查环境变量文件
|
||||
if [ ! -f ".env" ]; then
|
||||
echo -e "${YELLOW}警告: .env 文件不存在,从 .env.example 复制${NC}"
|
||||
if [ -f ".env.example" ]; then
|
||||
cp .env.example .env
|
||||
echo -e "${YELLOW}请编辑 .env 文件并设置正确的配置${NC}"
|
||||
exit 1
|
||||
else
|
||||
echo -e "${RED}错误: .env.example 文件也不存在${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 加载环境变量
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
# 函数:显示帮助信息
|
||||
show_help() {
|
||||
echo "H3C ONU设备管理系统 - 部署脚本"
|
||||
echo ""
|
||||
echo "使用方法: $0 [环境] [操作]"
|
||||
echo ""
|
||||
echo "环境:"
|
||||
echo " dev 开发环境"
|
||||
echo " prod 生产环境 (默认)"
|
||||
echo ""
|
||||
echo "操作:"
|
||||
echo " up 启动服务 (默认)"
|
||||
echo " down 停止服务"
|
||||
echo " restart 重启服务"
|
||||
echo " logs 查看日志"
|
||||
echo " build 构建镜像"
|
||||
echo " ps 查看服务状态"
|
||||
echo " exec 进入容器"
|
||||
echo " backup 备份数据"
|
||||
echo " restore 恢复数据"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 prod up # 启动生产环境"
|
||||
echo " $0 dev logs # 查看开发环境日志"
|
||||
echo " $0 prod restart # 重启生产环境"
|
||||
}
|
||||
|
||||
# 函数:检查Docker是否运行
|
||||
check_docker() {
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo -e "${RED}错误: Docker未运行或当前用户无权限${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:检查Docker Compose是否安装
|
||||
check_docker_compose() {
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
echo -e "${RED}错误: Docker Compose未安装${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:启动服务
|
||||
start_services() {
|
||||
echo -e "${GREEN}启动服务...${NC}"
|
||||
docker-compose -f "$COMPOSE_FILE" up -d
|
||||
|
||||
# 等待服务启动
|
||||
echo -e "${BLUE}等待服务启动...${NC}"
|
||||
sleep 10
|
||||
|
||||
# 检查服务状态
|
||||
check_services_status
|
||||
}
|
||||
|
||||
# 函数:停止服务
|
||||
stop_services() {
|
||||
echo -e "${YELLOW}停止服务...${NC}"
|
||||
docker-compose -f "$COMPOSE_FILE" down
|
||||
}
|
||||
|
||||
# 函数:重启服务
|
||||
restart_services() {
|
||||
echo -e "${YELLOW}重启服务...${NC}"
|
||||
docker-compose -f "$COMPOSE_FILE" restart
|
||||
}
|
||||
|
||||
# 函数:查看日志
|
||||
show_logs() {
|
||||
echo -e "${BLUE}查看日志 (Ctrl+C退出)...${NC}"
|
||||
docker-compose -f "$COMPOSE_FILE" logs -f
|
||||
}
|
||||
|
||||
# 函数:构建镜像
|
||||
build_images() {
|
||||
echo -e "${GREEN}构建Docker镜像...${NC}"
|
||||
docker-compose -f "$COMPOSE_FILE" build --no-cache
|
||||
|
||||
# 清理未使用的镜像
|
||||
echo -e "${BLUE}清理未使用的镜像...${NC}"
|
||||
docker image prune -f
|
||||
}
|
||||
|
||||
# 函数:查看服务状态
|
||||
show_status() {
|
||||
echo -e "${BLUE}服务状态:${NC}"
|
||||
docker-compose -f "$COMPOSE_FILE" ps
|
||||
|
||||
echo -e "\n${BLUE}容器资源使用:${NC}"
|
||||
docker stats --no-stream $(docker-compose -f "$COMPOSE_FILE" ps -q)
|
||||
}
|
||||
|
||||
# 函数:进入容器
|
||||
exec_container() {
|
||||
echo -e "${BLUE}选择要进入的容器:${NC}"
|
||||
echo "1) backend (后端API)"
|
||||
echo "2) frontend (前端)"
|
||||
echo "3) celery-worker (Celery Worker)"
|
||||
echo "4) celery-beat (Celery Beat)"
|
||||
echo "5) nginx (Nginx)"
|
||||
echo "6) redis (Redis)"
|
||||
|
||||
read -p "请输入数字 (1-6): " choice
|
||||
|
||||
case $choice in
|
||||
1) CONTAINER="backend" ;;
|
||||
2) CONTAINER="frontend" ;;
|
||||
3) CONTAINER="celery-worker" ;;
|
||||
4) CONTAINER="celery-beat" ;;
|
||||
5) CONTAINER="nginx" ;;
|
||||
6) CONTAINER="redis" ;;
|
||||
*)
|
||||
echo -e "${RED}无效的选择${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo -e "${GREEN}进入 $CONTAINER 容器...${NC}"
|
||||
docker-compose -f "$COMPOSE_FILE" exec "$CONTAINER" sh
|
||||
}
|
||||
|
||||
# 函数:备份数据
|
||||
backup_data() {
|
||||
BACKUP_DIR="backups/$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
echo -e "${GREEN}开始备份数据...${NC}"
|
||||
|
||||
# 备份数据库
|
||||
echo -e "${BLUE}备份数据库...${NC}"
|
||||
DB_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q backend)
|
||||
if [ -n "$DB_CONTAINER" ]; then
|
||||
docker exec "$DB_CONTAINER" pg_dump "$DATABASE_URL" > "$BACKUP_DIR/database.sql"
|
||||
echo "数据库备份完成: $BACKUP_DIR/database.sql"
|
||||
else
|
||||
echo -e "${YELLOW}警告: 数据库容器未运行${NC}"
|
||||
fi
|
||||
|
||||
# 备份Redis数据
|
||||
echo -e "${BLUE}备份Redis数据...${NC}"
|
||||
REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis)
|
||||
if [ -n "$REDIS_CONTAINER" ]; then
|
||||
docker exec "$REDIS_CONTAINER" redis-cli --rdb /data/dump.rdb
|
||||
docker cp "$REDIS_CONTAINER:/data/dump.rdb" "$BACKUP_DIR/redis.rdb"
|
||||
echo "Redis备份完成: $BACKUP_DIR/redis.rdb"
|
||||
else
|
||||
echo -e "${YELLOW}警告: Redis容器未运行${NC}"
|
||||
fi
|
||||
|
||||
# 备份配置文件
|
||||
echo -e "${BLUE}备份配置文件...${NC}"
|
||||
cp .env "$BACKUP_DIR/"
|
||||
cp "$COMPOSE_FILE" "$BACKUP_DIR/"
|
||||
|
||||
# 创建备份信息文件
|
||||
echo "备份时间: $(date)" > "$BACKUP_DIR/backup.info"
|
||||
echo "环境: $ENV" >> "$BACKUP_DIR/backup.info"
|
||||
echo "版本: $(git describe --tags 2>/dev/null || echo '未知')" >> "$BACKUP_DIR/backup.info"
|
||||
|
||||
# 压缩备份文件
|
||||
echo -e "${BLUE}压缩备份文件...${NC}"
|
||||
tar -czf "$BACKUP_DIR.tar.gz" "$BACKUP_DIR"
|
||||
rm -rf "$BACKUP_DIR"
|
||||
|
||||
echo -e "${GREEN}备份完成: $BACKUP_DIR.tar.gz${NC}"
|
||||
}
|
||||
|
||||
# 函数:恢复数据
|
||||
restore_data() {
|
||||
echo -e "${YELLOW}警告: 恢复数据将覆盖现有数据${NC}"
|
||||
read -p "请输入要恢复的备份文件路径: " BACKUP_FILE
|
||||
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
echo -e "${RED}错误: 备份文件不存在${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 解压备份文件
|
||||
BACKUP_DIR="${BACKUP_FILE%.tar.gz}"
|
||||
echo -e "${BLUE}解压备份文件...${NC}"
|
||||
tar -xzf "$BACKUP_FILE"
|
||||
|
||||
# 停止服务
|
||||
echo -e "${BLUE}停止服务...${NC}"
|
||||
docker-compose -f "$COMPOSE_FILE" down
|
||||
|
||||
# 恢复数据库
|
||||
if [ -f "$BACKUP_DIR/database.sql" ]; then
|
||||
echo -e "${BLUE}恢复数据库...${NC}"
|
||||
# 这里需要根据实际情况调整数据库恢复命令
|
||||
# 例如: psql -d database -f backup.sql
|
||||
echo "数据库恢复命令需要根据实际情况配置"
|
||||
fi
|
||||
|
||||
# 恢复Redis
|
||||
if [ -f "$BACKUP_DIR/redis.rdb" ]; then
|
||||
echo -e "${BLUE}恢复Redis数据...${NC}"
|
||||
# 这里需要根据实际情况调整Redis恢复命令
|
||||
echo "Redis恢复命令需要根据实际情况配置"
|
||||
fi
|
||||
|
||||
# 恢复配置文件
|
||||
if [ -f "$BACKUP_DIR/.env" ]; then
|
||||
echo -e "${BLUE}恢复配置文件...${NC}"
|
||||
cp "$BACKUP_DIR/.env" .
|
||||
fi
|
||||
|
||||
# 清理临时文件
|
||||
rm -rf "$BACKUP_DIR"
|
||||
|
||||
# 启动服务
|
||||
echo -e "${BLUE}启动服务...${NC}"
|
||||
docker-compose -f "$COMPOSE_FILE" up -d
|
||||
|
||||
echo -e "${GREEN}数据恢复完成${NC}"
|
||||
}
|
||||
|
||||
# 函数:检查服务状态
|
||||
check_services_status() {
|
||||
echo -e "${BLUE}检查服务健康状态...${NC}"
|
||||
|
||||
# 检查后端服务
|
||||
if curl -f http://localhost:8000/health > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ 后端服务运行正常${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ 后端服务异常${NC}"
|
||||
fi
|
||||
|
||||
# 检查前端服务
|
||||
if curl -f http://localhost:8080 > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ 前端服务运行正常${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ 前端服务异常${NC}"
|
||||
fi
|
||||
|
||||
# 检查Redis
|
||||
REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis)
|
||||
if [ -n "$REDIS_CONTAINER" ] && docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ Redis服务运行正常${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Redis服务异常${NC}"
|
||||
fi
|
||||
|
||||
echo -e "\n${GREEN}部署完成!${NC}"
|
||||
echo -e "前端访问: ${BLUE}http://localhost:8080${NC}"
|
||||
echo -e "API文档: ${BLUE}http://localhost:8000/docs${NC}"
|
||||
echo -e "健康检查: ${BLUE}http://localhost:8000/health${NC}"
|
||||
}
|
||||
|
||||
# 主程序
|
||||
main() {
|
||||
# 显示标题
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} H3C ONU设备管理系统 - 部署工具${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 检查依赖
|
||||
check_docker
|
||||
check_docker_compose
|
||||
|
||||
# 根据操作执行相应命令
|
||||
case "$ACTION" in
|
||||
up)
|
||||
start_services
|
||||
;;
|
||||
down)
|
||||
stop_services
|
||||
;;
|
||||
restart)
|
||||
restart_services
|
||||
;;
|
||||
logs)
|
||||
show_logs
|
||||
;;
|
||||
build)
|
||||
build_images
|
||||
;;
|
||||
ps)
|
||||
show_status
|
||||
;;
|
||||
exec)
|
||||
exec_container
|
||||
;;
|
||||
backup)
|
||||
backup_data
|
||||
;;
|
||||
restore)
|
||||
restore_data
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}错误: 未知操作 '$ACTION'${NC}"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 执行主程序
|
||||
main "$@"
|
||||
Executable
+389
@@ -0,0 +1,389 @@
|
||||
#!/bin/bash
|
||||
|
||||
# H3C ONU设备管理系统 - 数据库初始化脚本
|
||||
# 用于初始化数据库和创建基础数据
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 加载环境变量
|
||||
if [ -f "../.env" ]; then
|
||||
set -a
|
||||
source ../.env
|
||||
set +a
|
||||
else
|
||||
echo -e "${RED}错误: .env 文件不存在${NC}"
|
||||
echo "请先复制 .env.example 为 .env 并配置数据库连接"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查数据库连接字符串
|
||||
if [ -z "$DATABASE_URL" ]; then
|
||||
echo -e "${RED}错误: DATABASE_URL 未设置${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 从DATABASE_URL解析连接信息
|
||||
parse_db_url() {
|
||||
local url="$1"
|
||||
# 格式: postgresql://username:password@host:port/database
|
||||
local pattern="postgresql://([^:]+):([^@]+)@([^:]+):([^/]+)/(.+)"
|
||||
|
||||
if [[ $url =~ $pattern ]]; then
|
||||
DB_USER="${BASH_REMATCH[1]}"
|
||||
DB_PASSWORD="${BASH_REMATCH[2]}"
|
||||
DB_HOST="${BASH_REMATCH[3]}"
|
||||
DB_PORT="${BASH_REMATCH[4]}"
|
||||
DB_NAME="${BASH_REMATCH[5]}"
|
||||
else
|
||||
echo -e "${RED}错误: 无法解析DATABASE_URL${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
parse_db_url "$DATABASE_URL"
|
||||
|
||||
# 函数:检查PostgreSQL连接
|
||||
check_postgres_connection() {
|
||||
echo -e "${BLUE}检查PostgreSQL连接...${NC}"
|
||||
|
||||
if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "SELECT 1;" > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ PostgreSQL连接成功${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗ PostgreSQL连接失败${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:检查数据库是否存在
|
||||
check_database_exists() {
|
||||
echo -e "${BLUE}检查数据库 '$DB_NAME' 是否存在...${NC}"
|
||||
|
||||
if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ 数据库已存在${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${YELLOW}数据库不存在${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:创建数据库
|
||||
create_database() {
|
||||
echo -e "${BLUE}创建数据库 '$DB_NAME'...${NC}"
|
||||
|
||||
# 创建数据库
|
||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres <<EOF
|
||||
CREATE DATABASE $DB_NAME;
|
||||
EOF
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ 数据库创建成功${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ 数据库创建失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:创建数据库用户(如果不存在)
|
||||
create_database_user() {
|
||||
echo -e "${BLUE}检查数据库用户 '$DB_USER'...${NC}"
|
||||
|
||||
# 检查用户是否存在
|
||||
USER_EXISTS=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'")
|
||||
|
||||
if [ "$USER_EXISTS" = "1" ]; then
|
||||
echo -e "${GREEN}✓ 用户已存在${NC}"
|
||||
else
|
||||
echo -e "${BLUE}创建用户 '$DB_USER'...${NC}"
|
||||
|
||||
# 需要以postgres用户创建新用户
|
||||
# 这里假设有postgres用户的密码或者使用trust认证
|
||||
echo -e "${YELLOW}注意: 需要以postgres用户权限创建新用户${NC}"
|
||||
echo "请手动执行以下命令:"
|
||||
echo " psql -h $DB_HOST -p $DB_PORT -U postgres -d postgres"
|
||||
echo " CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';"
|
||||
echo " GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;"
|
||||
|
||||
read -p "是否已手动创建用户? (y/n): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${YELLOW}跳过用户创建${NC}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:授予权限
|
||||
grant_privileges() {
|
||||
echo -e "${BLUE}授予用户权限...${NC}"
|
||||
|
||||
# 授予数据库权限
|
||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres <<EOF
|
||||
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
|
||||
EOF
|
||||
|
||||
# 授予schema权限
|
||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" <<EOF
|
||||
GRANT ALL ON SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO $DB_USER;
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}✓ 权限授予成功${NC}"
|
||||
}
|
||||
|
||||
# 函数:运行数据库迁移
|
||||
run_migrations() {
|
||||
echo -e "${BLUE}运行数据库迁移...${NC}"
|
||||
|
||||
# 检查后端目录是否存在
|
||||
if [ ! -d "../../backend" ]; then
|
||||
echo -e "${YELLOW}警告: 后端目录不存在,跳过迁移${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
# 进入后端目录
|
||||
cd "../../backend"
|
||||
|
||||
# 检查Alembic配置
|
||||
if [ ! -f "alembic.ini" ]; then
|
||||
echo -e "${YELLOW}警告: alembic.ini 不存在,跳过迁移${NC}"
|
||||
cd - > /dev/null
|
||||
return
|
||||
fi
|
||||
|
||||
# 运行迁移
|
||||
if command -v alembic &> /dev/null; then
|
||||
# 设置数据库URL环境变量
|
||||
export DATABASE_URL
|
||||
|
||||
# 初始化迁移(如果第一次运行)
|
||||
if [ ! -d "alembic/versions" ]; then
|
||||
echo -e "${BLUE}初始化Alembic迁移...${NC}"
|
||||
alembic init alembic
|
||||
# 需要手动配置alembic.ini中的sqlalchemy.url
|
||||
fi
|
||||
|
||||
# 创建迁移版本
|
||||
echo -e "${BLUE}创建迁移版本...${NC}"
|
||||
alembic revision --autogenerate -m "初始迁移"
|
||||
|
||||
# 应用迁移
|
||||
echo -e "${BLUE}应用迁移...${NC}"
|
||||
alembic upgrade head
|
||||
|
||||
echo -e "${GREEN}✓ 数据库迁移完成${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}警告: alembic 命令未找到,跳过迁移${NC}"
|
||||
echo "请安装: pip install alembic"
|
||||
fi
|
||||
|
||||
# 返回原目录
|
||||
cd - > /dev/null
|
||||
}
|
||||
|
||||
# 函数:创建基础数据
|
||||
create_base_data() {
|
||||
echo -e "${BLUE}创建基础数据...${NC}"
|
||||
|
||||
# 检查数据库连接
|
||||
if ! check_database_exists; then
|
||||
echo -e "${RED}错误: 数据库不存在,无法创建基础数据${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 创建基础数据SQL
|
||||
BASE_DATA_SQL=$(cat <<EOF
|
||||
-- 创建系统配置
|
||||
INSERT INTO system_config (config_key, config_value, description) VALUES
|
||||
('check_interval', '1800', '自动检查间隔(秒)'),
|
||||
('manual_cooldown', '300', '手动刷新冷却时间(秒)'),
|
||||
('history_retention', '90', '历史记录保留天数'),
|
||||
('ssh_timeout', '30', 'SSH连接超时时间'),
|
||||
('ssh_max_connections', '10', 'SSH最大连接数'),
|
||||
('batch_check_size', '100', '批量检查设备数量')
|
||||
ON CONFLICT (config_key) DO NOTHING;
|
||||
|
||||
-- 创建默认角色
|
||||
INSERT INTO roles (name, description, is_system) VALUES
|
||||
('super_admin', '超级管理员', true),
|
||||
('admin', '管理员', true),
|
||||
('area_admin', '区域管理员', true),
|
||||
('school_admin', '学校管理员', true),
|
||||
('operator', '操作员', true),
|
||||
('viewer', '查看员', true)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- 创建基础权限
|
||||
INSERT INTO permissions (name, code, module, description) VALUES
|
||||
-- 设备模块权限
|
||||
('设备查看', 'device_view', 'device', '查看设备信息'),
|
||||
('设备编辑', 'device_edit', 'device', '编辑设备信息'),
|
||||
('设备删除', 'device_delete', 'device', '删除设备'),
|
||||
('设备导入', 'device_import', 'device', '导入设备数据'),
|
||||
('设备检查', 'device_check', 'device', '检查设备状态'),
|
||||
-- 用户模块权限
|
||||
('用户查看', 'user_view', 'user', '查看用户信息'),
|
||||
('用户管理', 'user_manage', 'user', '管理用户'),
|
||||
('角色管理', 'role_manage', 'user', '管理角色'),
|
||||
('权限管理', 'permission_manage', 'user', '管理权限'),
|
||||
-- 系统模块权限
|
||||
('系统配置', 'system_config', 'system', '系统配置管理'),
|
||||
('OLT管理', 'olt_manage', 'system', 'OLT设备管理'),
|
||||
('任务管理', 'task_manage', 'system', '任务配置管理'),
|
||||
('审核管理', 'audit_manage', 'system', '审核流程管理'),
|
||||
-- 统计模块权限
|
||||
('统计查看', 'stats_view', 'stats', '查看统计信息'),
|
||||
('报表导出', 'report_export', 'stats', '导出报表')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- 为超级管理员角色分配所有权限
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
CROSS JOIN permissions p
|
||||
WHERE r.name = 'super_admin'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 为管理员角色分配大部分权限(除了用户管理相关)
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
CROSS JOIN permissions p
|
||||
WHERE r.name = 'admin'
|
||||
AND p.code NOT IN ('user_manage', 'role_manage', 'permission_manage')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 为区域管理员分配基础权限
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
CROSS JOIN permissions p
|
||||
WHERE r.name = 'area_admin'
|
||||
AND p.code IN ('device_view', 'device_edit', 'device_check', 'stats_view')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 为学校管理员分配基础权限
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
CROSS JOIN permissions p
|
||||
WHERE r.name = 'school_admin'
|
||||
AND p.code IN ('device_view', 'device_edit', 'device_check')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 为操作员分配基础权限
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
CROSS JOIN permissions p
|
||||
WHERE r.name = 'operator'
|
||||
AND p.code IN ('device_view', 'device_edit', 'device_check')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 为查看员分配只读权限
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
CROSS JOIN permissions p
|
||||
WHERE r.name = 'viewer'
|
||||
AND p.code IN ('device_view', 'stats_view')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 创建默认系统用户(通过Casdoor同步,这里只是占位)
|
||||
INSERT INTO users (casdoor_id, username, email, role, is_active) VALUES
|
||||
('system', '系统用户', 'system@example.com', 'super_admin', true)
|
||||
ON CONFLICT (casdoor_id) DO NOTHING;
|
||||
|
||||
EOF
|
||||
)
|
||||
|
||||
# 执行SQL
|
||||
echo "$BASE_DATA_SQL" | PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ 基础数据创建成功${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}警告: 基础数据创建可能失败,表可能不存在${NC}"
|
||||
echo "请先运行数据库迁移"
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:显示数据库信息
|
||||
show_database_info() {
|
||||
echo -e "\n${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} 数据库初始化完成${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}数据库连接信息:${NC}"
|
||||
echo " 主机: $DB_HOST"
|
||||
echo " 端口: $DB_PORT"
|
||||
echo " 数据库: $DB_NAME"
|
||||
echo " 用户: $DB_USER"
|
||||
echo ""
|
||||
echo -e "${BLUE}连接字符串:${NC}"
|
||||
echo " $DATABASE_URL"
|
||||
echo ""
|
||||
echo -e "${BLUE}下一步:${NC}"
|
||||
echo " 1. 启动后端服务运行数据库迁移"
|
||||
echo " 2. 配置Casdoor认证"
|
||||
echo " 3. 导入设备数据"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 主程序
|
||||
main() {
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} H3C ONU设备管理系统 - 数据库初始化${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 检查PostgreSQL连接
|
||||
if ! check_postgres_connection; then
|
||||
echo -e "${RED}请检查PostgreSQL服务是否运行,以及连接信息是否正确${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查数据库是否存在
|
||||
if check_database_exists; then
|
||||
echo -e "${YELLOW}数据库已存在,是否重新初始化?${NC}"
|
||||
read -p "重新初始化将删除现有数据! (y/n): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo -e "${YELLOW}删除数据库 '$DB_NAME'...${NC}"
|
||||
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;"
|
||||
create_database
|
||||
else
|
||||
echo -e "${YELLOW}跳过数据库创建${NC}"
|
||||
fi
|
||||
else
|
||||
create_database
|
||||
fi
|
||||
|
||||
# 创建用户和授予权限
|
||||
create_database_user
|
||||
grant_privileges
|
||||
|
||||
# 运行迁移(如果后端代码已存在)
|
||||
run_migrations
|
||||
|
||||
# 创建基础数据
|
||||
create_base_data
|
||||
|
||||
# 显示完成信息
|
||||
show_database_info
|
||||
}
|
||||
|
||||
# 执行主程序
|
||||
main "$@"
|
||||
Executable
+368
@@ -0,0 +1,368 @@
|
||||
#!/bin/bash
|
||||
|
||||
# H3C ONU设备管理系统 - 系统监控脚本
|
||||
# 用于监控系统状态、资源使用和性能指标
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
PURPLE='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 默认值
|
||||
INTERVAL=${1:-5} # 监控间隔(秒)
|
||||
DURATION=${2:-0} # 监控时长(0表示无限)
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
|
||||
# 加载环境变量
|
||||
if [ -f ".env" ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
# 函数:显示帮助信息
|
||||
show_help() {
|
||||
echo "H3C ONU设备管理系统 - 系统监控脚本"
|
||||
echo ""
|
||||
echo "使用方法: $0 [间隔] [时长]"
|
||||
echo ""
|
||||
echo "参数:"
|
||||
echo " 间隔 监控间隔(秒,默认: 5)"
|
||||
echo " 时长 监控总时长(秒,0表示无限,默认: 0)"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 # 每5秒监控一次,无限时长"
|
||||
echo " $0 10 60 # 每10秒监控一次,持续60秒"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 函数:检查Docker Compose服务状态
|
||||
check_services_status() {
|
||||
echo -e "${BLUE}=== 服务状态 ===${NC}"
|
||||
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
echo -e "${RED}错误: docker-compose 未安装${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
# 获取服务状态
|
||||
SERVICES=$(docker-compose -f "$COMPOSE_FILE" ps --services)
|
||||
|
||||
for service in $SERVICES; do
|
||||
STATUS=$(docker-compose -f "$COMPOSE_FILE" ps --filter "name=$service" --format "table {{.Status}}" | tail -1)
|
||||
CONTAINER_ID=$(docker-compose -f "$COMPOSE_FILE" ps -q "$service")
|
||||
|
||||
if [ -n "$CONTAINER_ID" ]; then
|
||||
# 检查容器是否运行
|
||||
if docker inspect --format='{{.State.Status}}' "$CONTAINER_ID" | grep -q "running"; then
|
||||
# 检查健康状态
|
||||
HEALTH=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}no healthcheck{{end}}' "$CONTAINER_ID")
|
||||
|
||||
if [ "$HEALTH" = "healthy" ]; then
|
||||
echo -e "${GREEN}✓ $service: 运行中 (健康)${NC}"
|
||||
elif [ "$HEALTH" = "no healthcheck" ]; then
|
||||
echo -e "${GREEN}✓ $service: 运行中${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ $service: 运行中 ($HEALTH)${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗ $service: 未运行${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗ $service: 容器不存在${NC}"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 函数:检查系统资源使用
|
||||
check_system_resources() {
|
||||
echo -e "\n${BLUE}=== 系统资源 ===${NC}"
|
||||
|
||||
# CPU使用率
|
||||
CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)
|
||||
echo -e "CPU使用率: ${CYAN}${CPU_USAGE}%${NC}"
|
||||
|
||||
# 内存使用
|
||||
MEM_TOTAL=$(free -m | awk '/Mem:/ {print $2}')
|
||||
MEM_USED=$(free -m | awk '/Mem:/ {print $3}')
|
||||
MEM_PERCENT=$((MEM_USED * 100 / MEM_TOTAL))
|
||||
|
||||
if [ $MEM_PERCENT -lt 70 ]; then
|
||||
echo -e "内存使用: ${GREEN}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}"
|
||||
elif [ $MEM_PERCENT -lt 90 ]; then
|
||||
echo -e "内存使用: ${YELLOW}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}"
|
||||
else
|
||||
echo -e "内存使用: ${RED}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}"
|
||||
fi
|
||||
|
||||
# 磁盘使用
|
||||
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}' | cut -d'%' -f1)
|
||||
DISK_TOTAL=$(df -h / | awk 'NR==2 {print $2}')
|
||||
DISK_USED=$(df -h / | awk 'NR==2 {print $3}')
|
||||
|
||||
if [ $DISK_USAGE -lt 70 ]; then
|
||||
echo -e "磁盘使用: ${GREEN}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}"
|
||||
elif [ $DISK_USAGE -lt 90 ]; then
|
||||
echo -e "磁盘使用: ${YELLOW}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}"
|
||||
else
|
||||
echo -e "磁盘使用: ${RED}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:检查容器资源使用
|
||||
check_container_resources() {
|
||||
echo -e "\n${BLUE}=== 容器资源 ===${NC}"
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}错误: docker 未安装${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
# 获取所有运行中的容器
|
||||
CONTAINERS=$(docker ps --format "{{.Names}}")
|
||||
|
||||
for container in $CONTAINERS; do
|
||||
# 获取容器资源使用
|
||||
STATS=$(docker stats --no-stream --format "{{.CPUPerc}} {{.MemUsage}} {{.MemPerc}}" "$container" 2>/dev/null || echo "0% 0B/0B 0%")
|
||||
|
||||
CPU=$(echo "$STATS" | awk '{print $1}')
|
||||
MEM_USAGE=$(echo "$STATS" | awk '{print $2}')
|
||||
MEM_PERCENT=$(echo "$STATS" | awk '{print $3}' | cut -d'%' -f1)
|
||||
|
||||
# 检查是否属于本项目
|
||||
if echo "$container" | grep -q "h3c-onu-ms"; then
|
||||
COLOR="$CYAN"
|
||||
else
|
||||
COLOR="$PURPLE"
|
||||
fi
|
||||
|
||||
echo -e "${COLOR}$container${NC}: CPU=${CPU}, 内存=${MEM_USAGE} (${MEM_PERCENT}%)"
|
||||
done
|
||||
}
|
||||
|
||||
# 函数:检查应用健康状态
|
||||
check_application_health() {
|
||||
echo -e "\n${BLUE}=== 应用健康 ===${NC}"
|
||||
|
||||
# 检查后端API
|
||||
if curl -s -f http://localhost:8000/health > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ 后端API: 健康${NC}"
|
||||
|
||||
# 获取详细健康信息
|
||||
HEALTH_INFO=$(curl -s http://localhost:8000/health)
|
||||
echo " 详细信息: $HEALTH_INFO"
|
||||
else
|
||||
echo -e "${RED}✗ 后端API: 不可用${NC}"
|
||||
fi
|
||||
|
||||
# 检查前端
|
||||
if curl -s -f http://localhost:8080 > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ 前端应用: 可访问${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ 前端应用: 不可访问${NC}"
|
||||
fi
|
||||
|
||||
# 检查Redis
|
||||
REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis 2>/dev/null || echo "")
|
||||
if [ -n "$REDIS_CONTAINER" ]; then
|
||||
if docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ Redis: 运行正常${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Redis: 连接失败${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查数据库连接
|
||||
if [ -n "$DATABASE_URL" ]; then
|
||||
# 简单检查数据库连接
|
||||
if command -v pg_isready &> /dev/null; then
|
||||
# 从DATABASE_URL解析主机和端口
|
||||
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's/.*@\([^:]*\).*/\1/p')
|
||||
DB_PORT=$(echo "$DATABASE_URL" | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
|
||||
|
||||
if pg_isready -h "$DB_HOST" -p "$DB_PORT" > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ 数据库: 可连接${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ 数据库: 连接失败${NC}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:检查网络连接
|
||||
check_network() {
|
||||
echo -e "\n${BLUE}=== 网络连接 ===${NC}"
|
||||
|
||||
# 检查互联网连接
|
||||
if ping -c 1 -W 2 8.8.8.8 > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ 互联网: 可连接${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ 互联网: 连接失败${NC}"
|
||||
fi
|
||||
|
||||
# 检查DNS
|
||||
if nslookup google.com > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ DNS: 工作正常${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ DNS: 解析失败${NC}"
|
||||
fi
|
||||
|
||||
# 检查端口监听
|
||||
echo -e "端口监听:"
|
||||
PORTS="80 443 8000 8080 5432 6379"
|
||||
for port in $PORTS; do
|
||||
if ss -tuln | grep -q ":$port "; then
|
||||
SERVICE=$(ss -tuln | grep ":$port " | awk '{print $5}' | cut -d':' -f2)
|
||||
echo -e " ${GREEN}✓ 端口 $port: 被监听 ($SERVICE)${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠ 端口 $port: 未监听${NC}"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 函数:检查日志错误
|
||||
check_log_errors() {
|
||||
echo -e "\n${BLUE}=== 日志检查 ===${NC}"
|
||||
|
||||
# 检查最近错误日志
|
||||
LOG_FILES="/var/log/syslog /var/log/messages /var/log/docker/*.log ../../backend/logs/*.log"
|
||||
|
||||
for log_file in $LOG_FILES; do
|
||||
if [ -f "$log_file" ]; then
|
||||
ERROR_COUNT=$(tail -100 "$log_file" | grep -i "error\|exception\|failed\|critical" | wc -l)
|
||||
if [ "$ERROR_COUNT" -gt 0 ]; then
|
||||
echo -e "${YELLOW}⚠ $log_file: 最近有 $ERROR_COUNT 个错误${NC}"
|
||||
|
||||
# 显示最近3个错误
|
||||
tail -100 "$log_file" | grep -i "error\|exception\|failed\|critical" | tail -3 | while read line; do
|
||||
echo " - $line"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 函数:检查定时任务
|
||||
check_scheduled_tasks() {
|
||||
echo -e "\n${BLUE}=== 定时任务 ===${NC}"
|
||||
|
||||
# 检查Celery Beat
|
||||
BEAT_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q celery-beat 2>/dev/null || echo "")
|
||||
if [ -n "$BEAT_CONTAINER" ]; then
|
||||
if docker logs --tail 10 "$BEAT_CONTAINER" 2>/dev/null | grep -q "beat"; then
|
||||
echo -e "${GREEN}✓ Celery Beat: 运行中${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Celery Beat: 可能异常${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查Celery Worker
|
||||
WORKER_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q celery-worker 2>/dev/null || echo "")
|
||||
if [ -n "$WORKER_CONTAINER" ]; then
|
||||
ACTIVE_TASKS=$(docker exec "$WORKER_CONTAINER" celery -A app.core.celery_app inspect active 2>/dev/null | grep -c "worker")
|
||||
if [ "$ACTIVE_TASKS" -gt 0 ]; then
|
||||
echo -e "${GREEN}✓ Celery Worker: 有 $ACTIVE_TASKS 个活跃任务${NC}"
|
||||
else
|
||||
echo -e "${CYAN}ℹ Celery Worker: 无活跃任务${NC}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:生成监控报告
|
||||
generate_report() {
|
||||
local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
|
||||
|
||||
echo -e "\n${PURPLE}========================================${NC}"
|
||||
echo -e "${PURPLE} 监控报告 - $timestamp${NC}"
|
||||
echo -e "${PURPLE}========================================${NC}"
|
||||
|
||||
check_services_status
|
||||
check_system_resources
|
||||
check_container_resources
|
||||
check_application_health
|
||||
check_network
|
||||
check_log_errors
|
||||
check_scheduled_tasks
|
||||
|
||||
echo -e "\n${PURPLE}========================================${NC}"
|
||||
}
|
||||
|
||||
# 函数:连续监控
|
||||
continuous_monitoring() {
|
||||
local start_time=$(date +%s)
|
||||
local iteration=1
|
||||
|
||||
echo -e "${GREEN}开始监控,间隔: ${INTERVAL}秒${NC}"
|
||||
if [ "$DURATION" -gt 0 ]; then
|
||||
echo -e "监控时长: ${DURATION}秒${NC}"
|
||||
else
|
||||
echo -e "监控时长: 无限${NC}"
|
||||
fi
|
||||
echo "按 Ctrl+C 停止监控"
|
||||
echo ""
|
||||
|
||||
while true; do
|
||||
# 检查是否达到监控时长
|
||||
if [ "$DURATION" -gt 0 ]; then
|
||||
local current_time=$(date +%s)
|
||||
local elapsed=$((current_time - start_time))
|
||||
|
||||
if [ $elapsed -ge $DURATION ]; then
|
||||
echo -e "\n${GREEN}监控时长达到,停止监控${NC}"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
|
||||
# 清屏并显示报告
|
||||
clear
|
||||
echo -e "${BLUE}监控迭代 #$iteration - $(date "+%H:%M:%S")${NC}"
|
||||
generate_report
|
||||
|
||||
# 等待间隔
|
||||
sleep "$INTERVAL"
|
||||
iteration=$((iteration + 1))
|
||||
done
|
||||
}
|
||||
|
||||
# 函数:单次监控
|
||||
single_monitoring() {
|
||||
generate_report
|
||||
}
|
||||
|
||||
# 主程序
|
||||
main() {
|
||||
# 显示标题
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} H3C ONU设备管理系统 - 系统监控${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# 检查参数
|
||||
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
||||
show_help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查Docker Compose文件
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
echo -e "${YELLOW}警告: $COMPOSE_FILE 不存在,使用默认配置${NC}"
|
||||
fi
|
||||
|
||||
# 选择监控模式
|
||||
if [ "$DURATION" -eq 0 ] && [ "$INTERVAL" -eq 5 ]; then
|
||||
# 默认单次监控
|
||||
single_monitoring
|
||||
else
|
||||
# 连续监控
|
||||
continuous_monitoring
|
||||
fi
|
||||
}
|
||||
|
||||
# 执行主程序
|
||||
main "$@"
|
||||
@@ -0,0 +1,32 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
volumes:
|
||||
- ./backend/logs:/app/logs
|
||||
restart: unless-stopped
|
||||
|
||||
celery-worker:
|
||||
build: ./backend
|
||||
command: celery -A app.core.celery_app worker --loglevel=info
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
restart: unless-stopped
|
||||
|
||||
celery-beat:
|
||||
build: ./backend
|
||||
command: celery -A app.core.celery_app beat --loglevel=info
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>H3C ONU设备管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1778
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "h3c-onu-ms-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.5",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.5.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const getLoginUrl = () => request.get('/auth/login')
|
||||
|
||||
export const handleCallback = (code, state) => request.post('/auth/callback', { code, state })
|
||||
|
||||
export const getProfile = () => request.get('/auth/profile')
|
||||
@@ -0,0 +1,3 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const triggerCheck = () => request.post('/check/status')
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const getDevices = (params) => request.get('/devices', { params })
|
||||
|
||||
export const getDevice = (id) => request.get(`/devices/${id}`)
|
||||
|
||||
export const getRegions = () => request.get('/devices/regions')
|
||||
|
||||
export const updateDevice = (id, data) => request.put(`/devices/${id}`, data)
|
||||
|
||||
export const provisionService = (data) => request.post('/provision/service', data)
|
||||
|
||||
export const clearAllStatus = () => request.delete('/devices/status/all')
|
||||
|
||||
export const refreshAllStatus = () => request.post('/olt/quick-scan')
|
||||
|
||||
export const refreshDevice = (id) => request.post(`/devices/${id}/refresh`)
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const uploadExcel = (formData) => {
|
||||
return request.post('/import/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
export const downloadTemplate = () => {
|
||||
return request.get('/import/template', {
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export const getSummary = () => request.get('/stats/summary')
|
||||
|
||||
export const getTrend = (days = 7) => request.get('/stats/trend', { params: { days } })
|
||||
|
||||
export const getByRegion = () => request.get('/stats/by-region')
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<el-container>
|
||||
<el-header>
|
||||
<div class="header-content">
|
||||
<h3>H3C ONU 设备管理系统</h3>
|
||||
<el-button @click="handleLogout">退出登录</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-container>
|
||||
<el-aside width="200px">
|
||||
<el-menu router>
|
||||
<el-menu-item index="/dashboard">仪表板</el-menu-item>
|
||||
<el-menu-item index="/devices">设备列表</el-menu-item>
|
||||
<el-menu-item index="/import">数据导入</el-menu-item>
|
||||
<el-menu-item index="/charts">统计图表</el-menu-item>
|
||||
<el-menu-item index="/olt">OLT管理</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-header {
|
||||
background: #545c64;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Layout from '../components/Layout.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue')
|
||||
},
|
||||
{
|
||||
path: '/callback',
|
||||
name: 'Callback',
|
||||
component: () => import('../views/Callback.vue')
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/dashboard',
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: '/devices',
|
||||
name: 'DeviceList',
|
||||
component: () => import('../views/DeviceList.vue')
|
||||
},
|
||||
{
|
||||
path: '/import',
|
||||
name: 'ImportData',
|
||||
component: () => import('../views/ImportData.vue')
|
||||
},
|
||||
{
|
||||
path: '/charts',
|
||||
name: 'Charts',
|
||||
component: () => import('../views/Charts.vue')
|
||||
},
|
||||
{
|
||||
path: '/olt',
|
||||
name: 'OltManage',
|
||||
component: () => import('../views/OltManage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
if (to.meta.requiresAuth && !authStore.token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && authStore.token) {
|
||||
next('/dashboard')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as authApi from '../api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const user = ref(null)
|
||||
|
||||
const setToken = (newToken) => {
|
||||
token.value = newToken
|
||||
localStorage.setItem('token', newToken)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return { token, user, setToken, logout }
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
request.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="callback-container">
|
||||
<el-card>
|
||||
<div v-if="loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<p>正在登录...</p>
|
||||
</div>
|
||||
<div v-else-if="error">
|
||||
<p>登录失败: {{ error }}</p>
|
||||
<el-button @click="$router.push('/login')">返回登录</el-button>
|
||||
<el-button type="info" @click="showDetails = !showDetails">详细信息</el-button>
|
||||
<pre v-if="showDetails" class="error-details">{{ errorDetails }}</pre>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { handleCallback } from '../api/auth'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const errorDetails = ref('')
|
||||
const showDetails = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const code = new URLSearchParams(window.location.search).get('code')
|
||||
const state = new URLSearchParams(window.location.search).get('state')
|
||||
|
||||
if (!code || !state) {
|
||||
error.value = '缺少认证参数'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await handleCallback(code, state)
|
||||
authStore.setToken(data.access_token)
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
error.value = err.message || '登录失败'
|
||||
errorDetails.value = err.response?.data?.detail || JSON.stringify(err, null, 2)
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.callback-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.error-details {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="charts-container">
|
||||
<el-card class="chart-card">
|
||||
<h3>设备状态趋势</h3>
|
||||
<div ref="trendChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="chart-card">
|
||||
<h3>区域设备分布</h3>
|
||||
<div ref="regionChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { getTrend, getByRegion } from '../api/stats'
|
||||
|
||||
const trendChart = ref(null)
|
||||
const regionChart = ref(null)
|
||||
|
||||
const initTrendChart = async () => {
|
||||
const { data } = await getTrend(7)
|
||||
const chart = echarts.init(trendChart.value)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['在线', '离线'] },
|
||||
xAxis: { type: 'category', data: data.map(d => d.date) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '在线', type: 'line', data: data.map(d => d.online), smooth: true },
|
||||
{ name: '离线', type: 'line', data: data.map(d => d.offline), smooth: true }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const initRegionChart = async () => {
|
||||
const { data } = await getByRegion()
|
||||
const chart = echarts.init(regionChart.value)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { orient: 'vertical', left: 'left' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: data.map(d => ({ value: d.total, name: d.region }))
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initTrendChart()
|
||||
initRegionChart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.charts-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="page-header">
|
||||
<span class="page-title">统计信息</span>
|
||||
<el-button size="small" :loading="loading" @click="loadData">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 顶部汇总卡片 -->
|
||||
<el-row :gutter="16" style="margin-bottom: 24px">
|
||||
<el-col :span="6" v-for="card in summaryCards" :key="card.key">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ card.label }}</div>
|
||||
<div class="stat-rate" :style="{ color: rateColor(card.rate) }">
|
||||
{{ card.total > 0 ? card.rate.toFixed(1) + '%' : '-' }}
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="dot online" />
|
||||
<span class="count-text">在线 {{ card.online }}</span>
|
||||
<span class="dot offline" style="margin-left: 12px" />
|
||||
<span class="count-text">离线 {{ card.offline }}</span>
|
||||
</div>
|
||||
<div class="stat-total">共 {{ card.total }} 台</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 详细列表 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-card class="list-card">
|
||||
<template #header><span class="card-title">城区学校在线率</span></template>
|
||||
<div class="school-list">
|
||||
<template v-if="data.urban?.schools?.length">
|
||||
<div v-for="s in data.urban.schools" :key="s.name" class="school-item">
|
||||
<div class="school-header">
|
||||
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
|
||||
<div class="school-sub">在线 {{ s.online }} / 共 {{ s.total }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card class="list-card">
|
||||
<template #header><span class="card-title">城郊学校在线率</span></template>
|
||||
<div class="school-list">
|
||||
<template v-if="data.suburban?.schools?.length">
|
||||
<div v-for="s in data.suburban.schools" :key="s.name" class="school-item">
|
||||
<div class="school-header">
|
||||
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
|
||||
<div class="school-sub">在线 {{ s.online }} / 共 {{ s.total }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card class="list-card">
|
||||
<template #header><span class="card-title">乡镇在线率</span></template>
|
||||
<div class="school-list">
|
||||
<template v-if="data.rural?.towns?.length">
|
||||
<div v-for="t in data.rural.towns" :key="t.region" class="school-item town-item" @click="openTown(t)">
|
||||
<div class="school-header">
|
||||
<span class="school-name town-name">{{ t.region }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(t)) }">
|
||||
{{ rate(t).toFixed(1) }}%
|
||||
<el-icon style="vertical-align: -2px; margin-left: 2px"><ArrowRight /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(t)" :stroke-width="5" :show-text="false" :color="rateColor(rate(t))" />
|
||||
<div class="school-sub">在线 {{ t.online }} / 共 {{ t.total }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 乡镇学校详情 -->
|
||||
<el-dialog v-model="townVisible" :title="selectedTown?.region + ' — 学校在线率'" width="480px" destroy-on-close>
|
||||
<div class="school-list" style="max-height: 500px; overflow-y: auto; padding-right: 4px">
|
||||
<div v-for="s in selectedTown?.schools" :key="s.name" class="school-item">
|
||||
<div class="school-header">
|
||||
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
|
||||
<div class="school-sub">在线 {{ s.online }} / 共 {{ s.total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="townVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ArrowRight } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '../utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref({})
|
||||
const townVisible = ref(false)
|
||||
const selectedTown = ref(null)
|
||||
|
||||
const rate = (item) => {
|
||||
if (!item || !item.total) return 0
|
||||
return (item.online / item.total) * 100
|
||||
}
|
||||
|
||||
const rateColor = (r) => {
|
||||
if (r >= 90) return '#67c23a'
|
||||
if (r >= 70) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
}
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{ key: 'overall', label: '总体在线率', ...(data.value.overall || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.overall) },
|
||||
{ key: 'urban', label: '城区在线率', ...(data.value.urban || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.urban) },
|
||||
{ key: 'suburban', label: '城郊在线率', ...(data.value.suburban || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.suburban) },
|
||||
{ key: 'rural', label: '乡镇在线率', ...(data.value.rural || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.rural) },
|
||||
])
|
||||
|
||||
const openTown = (town) => {
|
||||
selectedTown.value = town
|
||||
townVisible.value = true
|
||||
}
|
||||
|
||||
const goToSchool = (schoolName) => {
|
||||
townVisible.value = false
|
||||
router.push({ path: '/devices', query: { school_name: schoolName } })
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data: d } = await request.get('/stats/dashboard')
|
||||
data.value = d
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-rate {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.dot.online { background: #67c23a; }
|
||||
.dot.offline { background: #f56c6c; }
|
||||
|
||||
.count-text {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.school-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.school-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.town-item {
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.town-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.school-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.school-name {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.school-link {
|
||||
cursor: pointer;
|
||||
color: #409eff;
|
||||
}
|
||||
.school-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.town-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.school-rate {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.school-sub {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 13px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div class="device-list">
|
||||
<el-card>
|
||||
<el-form inline>
|
||||
<el-form-item label="区域">
|
||||
<el-select v-model="filters.region" placeholder="全部区域" clearable @change="search" style="width: 180px">
|
||||
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable @change="search" style="width: 100px">
|
||||
<el-option label="在线" value="online" />
|
||||
<el-option label="离线" value="offline" />
|
||||
<el-option label="未知" value="unknown" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="MAC/区域/学校/楼宇/房间号"
|
||||
clearable
|
||||
@clear="search"
|
||||
@keyup.enter="search"
|
||||
style="width: 260px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-left: auto">
|
||||
<el-button type="danger" plain @click="clearStatus" :loading="clearing">清空状态</el-button>
|
||||
<el-button type="warning" @click="refreshAllStatus" :loading="refreshing" style="margin-left: 8px">
|
||||
{{ refreshing ? '更新中...' : '全部更新' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="devices" border stripe>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="region" label="区域" width="100" />
|
||||
<el-table-column prop="school_name" label="学校名称" width="180" />
|
||||
<el-table-column prop="building" label="楼宇" width="130" />
|
||||
<el-table-column prop="place_type" label="场所类型" width="130" />
|
||||
<el-table-column prop="room_number" label="房间号" width="100" />
|
||||
<el-table-column prop="mac_address" label="MAC地址" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="openDetail(row)">
|
||||
{{ formatMac(row.mac_address) }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.status === 'online' ? 'success' : row.status === 'offline' ? 'danger' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ row.status === 'online' ? '在线' : row.status === 'offline' ? '离线' : '未知' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="distance_m" label="距离" width="90" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.distance_m ? row.distance_m + 'm' : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="notes" label="备注" min-width="100" show-overflow-tooltip />
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="loadDevices"
|
||||
@size-change="handleSizeChange"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
style="margin-top: 20px; justify-content: flex-end"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 检查结果对话框 -->
|
||||
<el-dialog v-model="checkResultVisible" title="全部更新结果" width="700px" destroy-on-close>
|
||||
<div v-if="checkInProgress" style="text-align: center; padding: 40px 0; color: #909399">
|
||||
正在扫描所有 OLT,请稍候...
|
||||
</div>
|
||||
|
||||
<div v-else-if="checkResult">
|
||||
<el-alert
|
||||
:type="checkResult.success ? 'success' : 'error'"
|
||||
:title="checkResult.success ? '更新完成' : '更新失败'"
|
||||
:description="checkResult.error || ''"
|
||||
:closable="false"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<template v-if="checkResult.success">
|
||||
<div style="display: flex; gap: 24px; margin-bottom: 16px">
|
||||
<el-statistic title="在线设备" :value="checkResult.total_online || 0" />
|
||||
<el-statistic title="离线设备" :value="checkResult.total_offline || 0" />
|
||||
<el-statistic title="失败 OLT" :value="(checkResult.errors || []).length" />
|
||||
</div>
|
||||
|
||||
<el-table :data="checkResult.results || []" border stripe size="small" max-height="300">
|
||||
<el-table-column prop="olt_location" label="OLT名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="在线" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" size="small">{{ row.online || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="离线" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="danger" size="small">{{ row.offline || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small">
|
||||
{{ row.success ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="checkResultVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="checkResultVisible = false; loadDevices()" v-if="!checkInProgress">
|
||||
刷新列表
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 设备详情对话框 -->
|
||||
<el-dialog v-model="detailVisible" title="设备详情" width="500px" destroy-on-close>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="MAC地址" :span="2">
|
||||
{{ formatMac(selectedDevice.mac_address) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="区域">{{ selectedDevice.region }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学校">{{ selectedDevice.school_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="楼宇">{{ selectedDevice.building || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="房间号">{{ selectedDevice.room_number || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="selectedDevice.status === 'online' ? 'success' : selectedDevice.status === 'offline' ? 'danger' : 'info'" size="small">
|
||||
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="距离">
|
||||
{{ selectedDevice.distance_m ? selectedDevice.distance_m + ' 米' : '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="端口" :span="2">
|
||||
{{ formatPort(selectedDevice) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="型号" :span="2">
|
||||
{{ selectedDevice.model || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="所属OLT" :span="2">
|
||||
{{ selectedDevice.olt_location || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ selectedDevice.notes || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template #footer>
|
||||
<el-button type="info" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新</el-button>
|
||||
<el-button type="primary" @click="openEdit">编辑</el-button>
|
||||
<el-button type="success" @click="openProvision" :disabled="selectedDevice.status !== 'online' || !selectedDevice.port_id">
|
||||
业务下发
|
||||
</el-button>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="editVisible" title="编辑设备信息" width="480px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="80px">
|
||||
<el-form-item label="区域">
|
||||
<el-input v-model="editForm.region" />
|
||||
</el-form-item>
|
||||
<el-form-item label="学校">
|
||||
<el-input v-model="editForm.school_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="楼宇">
|
||||
<el-input v-model="editForm.building" />
|
||||
</el-form-item>
|
||||
<el-form-item label="房间号">
|
||||
<el-input v-model="editForm.room_number" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editForm.notes" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSaving" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 业务下发对话框 -->
|
||||
<el-dialog v-model="provisionVisible" title="业务下发" width="560px" destroy-on-close>
|
||||
<el-alert
|
||||
v-if="provisionResult"
|
||||
:type="provisionResult.success ? 'success' : 'error'"
|
||||
:title="provisionResult.success ? '下发成功' : '下发失败'"
|
||||
:description="provisionResult.message"
|
||||
:closable="false"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<el-descriptions :column="2" border v-if="selectedDevice.mac_address">
|
||||
<el-descriptions-item label="MAC地址">{{ formatMac(selectedDevice.mac_address) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="端口">{{ formatPort(selectedDevice) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学校">{{ selectedDevice.school_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="selectedDevice.status === 'online' ? 'success' : selectedDevice.status === 'offline' ? 'danger' : 'info'" size="small">
|
||||
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider>将执行的配置</el-divider>
|
||||
<el-text type="info" size="small">
|
||||
<pre style="margin: 0; font-size: 12px; line-height: 1.6">system-view
|
||||
interface {{ formatPort(selectedDevice) }}
|
||||
uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
|
||||
port link-type trunk
|
||||
undo port trunk permit vlan 1
|
||||
port trunk permit vlan 2000 3000 to 3010 4094
|
||||
save force</pre>
|
||||
</el-text>
|
||||
<template #footer>
|
||||
<el-button @click="provisionVisible = false; provisionResult = null">关闭</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
@click="doProvision"
|
||||
:loading="provisionLoading"
|
||||
:disabled="selectedDevice.status !== 'online' || !selectedDevice.port_id"
|
||||
>
|
||||
确认下发
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as deviceApi from '../api/device'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const devices = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const regions = ref([])
|
||||
const filters = ref({ region: '', keyword: '', school_name: '', status: '' })
|
||||
|
||||
// 详情对话框
|
||||
const detailVisible = ref(false)
|
||||
const selectedDevice = ref({})
|
||||
const deviceRefreshing = ref(false)
|
||||
|
||||
// 业务下发对话框
|
||||
const provisionVisible = ref(false)
|
||||
const provisionLoading = ref(false)
|
||||
const provisionResult = ref(null)
|
||||
const refreshing = ref(false)
|
||||
const clearing = ref(false)
|
||||
|
||||
// 编辑对话框
|
||||
const editVisible = ref(false)
|
||||
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', notes: '' })
|
||||
const editSaving = ref(false)
|
||||
|
||||
// 检查结果对话框
|
||||
const checkResultVisible = ref(false)
|
||||
const checkResult = ref(null)
|
||||
const checkInProgress = ref(false)
|
||||
|
||||
// 清空所有设备状态
|
||||
const clearStatus = async () => {
|
||||
clearing.value = true
|
||||
try {
|
||||
await deviceApi.clearAllStatus()
|
||||
ElMessage.success('已清空所有设备状态')
|
||||
loadDevices()
|
||||
} catch {
|
||||
ElMessage.error('清空失败')
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新所有设备状态
|
||||
const refreshAllStatus = async () => {
|
||||
refreshing.value = true
|
||||
checkResultVisible.value = true
|
||||
checkInProgress.value = true
|
||||
checkResult.value = null
|
||||
|
||||
try {
|
||||
const { data } = await deviceApi.refreshAllStatus()
|
||||
checkResult.value = {
|
||||
success: true,
|
||||
total_online: data.total_online,
|
||||
total_offline: data.total_offline,
|
||||
results: data.results,
|
||||
errors: data.errors,
|
||||
}
|
||||
} catch (error) {
|
||||
checkResult.value = { success: false, error: error.response?.data?.detail || '更新失败' }
|
||||
} finally {
|
||||
checkInProgress.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化 MAC 地址
|
||||
const formatMac = (mac) => {
|
||||
if (!mac) return ''
|
||||
const clean = mac.toUpperCase().replace(/[:-]/g, '')
|
||||
if (clean.length !== 12) return mac.toLowerCase()
|
||||
return clean.substring(0, 4).toLowerCase() + '-' +
|
||||
clean.substring(4, 8).toLowerCase() + '-' +
|
||||
clean.substring(8, 12).toLowerCase()
|
||||
}
|
||||
|
||||
// 格式化端口显示
|
||||
const formatPort = (device) => {
|
||||
if (device.port_id) return `Onu${device.port_id}`
|
||||
if (!device.slot_number && !device.port_number) return '-'
|
||||
return `Onu${device.slot_number || '?'}/${device.port_number || '?'}`
|
||||
}
|
||||
|
||||
// 打开详情对话框
|
||||
const openDetail = (row) => {
|
||||
selectedDevice.value = { ...row }
|
||||
provisionResult.value = null
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// 单台设备状态更新
|
||||
const doRefreshDevice = async () => {
|
||||
deviceRefreshing.value = true
|
||||
try {
|
||||
const { data } = await deviceApi.refreshDevice(selectedDevice.value.id)
|
||||
selectedDevice.value.status = data.status
|
||||
selectedDevice.value.distance_m = data.distance_m
|
||||
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}${data.distance_m ? ',距离 ' + data.distance_m + 'm' : ''}`)
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '更新失败')
|
||||
} finally {
|
||||
deviceRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEdit = () => {
|
||||
editForm.value = {
|
||||
region: selectedDevice.value.region || '',
|
||||
school_name: selectedDevice.value.school_name || '',
|
||||
building: selectedDevice.value.building || '',
|
||||
room_number: selectedDevice.value.room_number || '',
|
||||
notes: selectedDevice.value.notes || '',
|
||||
}
|
||||
detailVisible.value = false
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
const saveEdit = async () => {
|
||||
editSaving.value = true
|
||||
try {
|
||||
await deviceApi.updateDevice(selectedDevice.value.id, editForm.value)
|
||||
ElMessage.success('保存成功')
|
||||
editVisible.value = false
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开业务下发对话框
|
||||
const openProvision = () => {
|
||||
provisionResult.value = null
|
||||
provisionVisible.value = true
|
||||
}
|
||||
|
||||
// 执行业务下发
|
||||
const doProvision = async () => {
|
||||
provisionLoading.value = true
|
||||
try {
|
||||
const { data } = await deviceApi.provisionService({ device_id: selectedDevice.value.id })
|
||||
provisionResult.value = { success: true, message: data.message }
|
||||
ElMessage.success(data.message)
|
||||
} catch (error) {
|
||||
const msg = error.response?.data?.detail || '下发失败'
|
||||
provisionResult.value = { success: false, message: msg }
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
provisionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载区域列表
|
||||
const loadRegions = async () => {
|
||||
const { data } = await deviceApi.getRegions()
|
||||
regions.value = data
|
||||
}
|
||||
|
||||
const loadDevices = async () => {
|
||||
const { data } = await deviceApi.getDevices({
|
||||
skip: (page.value - 1) * pageSize.value,
|
||||
limit: pageSize.value,
|
||||
region: filters.value.region || undefined,
|
||||
keyword: filters.value.keyword || undefined,
|
||||
school_name: filters.value.school_name || undefined,
|
||||
status: filters.value.status || undefined,
|
||||
})
|
||||
devices.value = data.items
|
||||
total.value = data.total
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
page.value = 1
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
page.value = 1
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.school_name) {
|
||||
filters.value.keyword = route.query.school_name
|
||||
}
|
||||
loadRegions()
|
||||
loadDevices()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.device-list {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="import-page">
|
||||
<el-card>
|
||||
<div class="header">
|
||||
<h2>数据导入</h2>
|
||||
<el-button type="success" @click="handleDownloadTemplate">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载模板
|
||||
</el-button>
|
||||
</div>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
accept=".xlsx,.xls"
|
||||
>
|
||||
<el-button type="primary">选择文件</el-button>
|
||||
</el-upload>
|
||||
<el-button @click="handleUpload" :loading="uploading" style="margin-top: 20px">
|
||||
开始导入
|
||||
</el-button>
|
||||
<div v-if="result" style="margin-top: 20px">
|
||||
<el-alert
|
||||
:title="`成功: ${result.success}`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
style="margin-bottom: 10px"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="result.failed && result.failed.length > 0"
|
||||
:title="`失败: ${result.failed.length}`"
|
||||
type="error"
|
||||
:closable="false"
|
||||
style="margin-bottom: 10px"
|
||||
/>
|
||||
<el-table
|
||||
v-if="result.failed && result.failed.length > 0"
|
||||
:data="result.failed"
|
||||
border
|
||||
style="margin-top: 10px"
|
||||
max-height="300"
|
||||
>
|
||||
<el-table-column label="MAC地址" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.record?.mac_address || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="record.school_name" label="学校名称" width="180" />
|
||||
<el-table-column prop="error" label="失败原因" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import * as importApi from '../api/import'
|
||||
|
||||
const file = ref(null)
|
||||
const uploading = ref(false)
|
||||
const result = ref(null)
|
||||
|
||||
const handleFileChange = (uploadFile) => {
|
||||
file.value = uploadFile.raw
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file.value) {
|
||||
ElMessage.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.value)
|
||||
|
||||
try {
|
||||
const { data } = await importApi.uploadExcel(formData)
|
||||
result.value = data
|
||||
ElMessage.success('导入完成')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '导入失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const response = await importApi.downloadTemplate()
|
||||
const blob = new Blob([response.data], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'onu_import_template.xlsx'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
ElMessage.error('模板下载失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.import-page {
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<el-card class="login-card">
|
||||
<h2>H3C ONU 设备管理系统</h2>
|
||||
<div v-if="!error">
|
||||
<el-icon class="is-loading" style="font-size: 24px; margin-bottom: 12px"><Loading /></el-icon>
|
||||
<p>正在跳转到登录页面...</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-alert type="error" :title="error" :closable="false" style="margin-bottom: 16px; text-align: left" />
|
||||
<el-button type="primary" @click="doRedirect">重试</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getLoginUrl } from '../api/auth'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
|
||||
const error = ref('')
|
||||
|
||||
const doRedirect = async () => {
|
||||
error.value = ''
|
||||
try {
|
||||
const { data } = await getLoginUrl()
|
||||
window.location.href = data.url
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.detail || e.message || '获取登录地址失败,请检查后端服务是否正常'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(doRedirect)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.login-card {
|
||||
width: 400px;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
p { color: #666; margin-bottom: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,644 @@
|
||||
<template>
|
||||
<div class="olt-manage">
|
||||
<el-button type="primary" @click="openAddDialog">添加 OLT 设备</el-button>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="handleImportFile"
|
||||
accept=".xlsx,.xls"
|
||||
style="display: inline-block; margin-left: 10px"
|
||||
>
|
||||
<el-button type="success" :loading="importing">批量导入</el-button>
|
||||
</el-upload>
|
||||
<el-button @click="downloadTemplate">下载模板</el-button>
|
||||
<el-button type="danger" plain @click="openDuplicateMacs" style="margin-left: 10px">
|
||||
重复 MAC 地址
|
||||
<el-badge v-if="duplicateCount > 0" :value="duplicateCount" style="margin-left: 4px" />
|
||||
</el-button>
|
||||
<el-button type="warning" plain @click="openNewDevices" style="margin-left: 10px">
|
||||
新增设备
|
||||
<el-badge v-if="newDeviceCount > 0" :value="newDeviceCount" style="margin-left: 4px" />
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="runLoopbackDetection" :loading="loopDetecting" style="margin-left: 10px">
|
||||
环路检测
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="runQuickScan" :loading="quickScanning" style="margin-left: 10px">
|
||||
快速扫描
|
||||
</el-button>
|
||||
|
||||
<el-table :data="devices" style="margin-top: 20px">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="ip_address" label="IP 地址" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="openDetail(row)">{{ row.ip_address }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="location" label="安装位置" width="150" />
|
||||
<el-table-column prop="slot_command" label="槽位命令" width="150" />
|
||||
<el-table-column prop="description" label="描述" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="环路状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="loopStatusMap[row.id]"
|
||||
type="danger"
|
||||
size="small"
|
||||
style="cursor: pointer"
|
||||
@click="showLoopDetail(row.id)"
|
||||
>
|
||||
环路 {{ loopStatusMap[row.id].length }} 个
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 快速扫描结果对话框 -->
|
||||
<el-dialog v-model="quickScanVisible" title="快速扫描结果" width="700px" destroy-on-close>
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 16px">
|
||||
<el-statistic title="在线设备" :value="quickScanResult.total_online || 0" />
|
||||
<el-statistic title="离线设备" :value="quickScanResult.total_offline || 0" />
|
||||
<el-statistic title="新入库设备" :value="quickScanResult.total_new || 0" />
|
||||
<el-statistic title="失败 OLT" :value="(quickScanResult.errors || []).length" />
|
||||
</div>
|
||||
<el-table :data="quickScanResult.results || []" border size="small" max-height="360">
|
||||
<el-table-column prop="olt_location" label="OLT" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="在线" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" size="small">{{ row.online }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="离线" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="danger" size="small">{{ row.offline }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新入库" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.new_discovered > 0" type="warning" size="small">{{ row.new_discovered }}</el-tag>
|
||||
<span v-else style="color: #c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small">
|
||||
{{ row.success ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="error" label="错误信息" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.error || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="quickScanVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 环路检测结果对话框 -->
|
||||
<el-dialog v-model="loopVisible" title="环路检测结果" width="860px" destroy-on-close>
|
||||
<div v-for="item in loopResults" :key="item.olt_id" style="margin-bottom: 16px">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px">
|
||||
<span style="font-weight: 600">{{ item.olt_location }}</span>
|
||||
<el-tag v-if="item.error" type="warning" size="small">连接失败</el-tag>
|
||||
<el-tag v-else-if="item.has_loop" type="danger" size="small">检测到环路 {{ item.loop_interfaces.length }} 个</el-tag>
|
||||
<el-tag v-else type="success" size="small">正常</el-tag>
|
||||
<span v-if="item.error" style="color: #e6a23c; font-size: 12px">{{ item.error }}</span>
|
||||
</div>
|
||||
<el-table v-if="item.has_loop" :data="item.loop_interfaces" border size="small">
|
||||
<el-table-column prop="interface" label="端口" width="140" />
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160">
|
||||
<template #default="{ row }">{{ row.mac_address || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="region" label="区域" width="100">
|
||||
<template #default="{ row }">{{ row.region || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="school_name" label="学校" min-width="140">
|
||||
<template #default="{ row }">{{ row.school_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="building" label="楼宇" width="100">
|
||||
<template #default="{ row }">{{ row.building || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="room_number" label="房间号" width="90">
|
||||
<template #default="{ row }">{{ row.room_number || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="loopVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 单台 OLT 环路详情对话框 -->
|
||||
<el-dialog v-model="loopDetailVisible" title="环路详情" width="700px" destroy-on-close>
|
||||
<el-table :data="loopDetailInterfaces" border size="small">
|
||||
<el-table-column prop="interface" label="端口" width="140" />
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160">
|
||||
<template #default="{ row }">{{ row.mac_address || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="region" label="区域" width="100">
|
||||
<template #default="{ row }">{{ row.region || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="school_name" label="学校" min-width="140">
|
||||
<template #default="{ row }">{{ row.school_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="building" label="楼宇" width="100">
|
||||
<template #default="{ row }">{{ row.building || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="room_number" label="房间号" width="90">
|
||||
<template #default="{ row }">{{ row.room_number || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="loopDetailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑 OLT 设备' : '添加 OLT 设备'" width="500px" destroy-on-close>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="IP 地址">
|
||||
<el-input v-model="form.ip_address" :disabled="isEdit" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" placeholder="不修改请留空" />
|
||||
</el-form-item>
|
||||
<el-form-item label="安装位置">
|
||||
<el-input v-model="form.location" />
|
||||
</el-form-item>
|
||||
<el-form-item label="槽位命令">
|
||||
<el-input v-model="form.slot_command" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveDevice">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="detailVisible" title="OLT 设备详情" width="500px" destroy-on-close>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="IP 地址" :span="2">{{ selectedDevice.ip_address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ selectedDevice.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="安装位置">{{ selectedDevice.location || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="槽位命令">{{ selectedDevice.slot_command || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{{ selectedDevice.description || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button type="success" :loading="scanning" @click="scanOlt">扫描发现设备</el-button>
|
||||
<el-button type="warning" :loading="discovering" @click="discoverOlt">扫描并入库</el-button>
|
||||
<el-button type="primary" @click="openEdit">编辑</el-button>
|
||||
<el-button type="danger" @click="confirmDelete">删除</el-button>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 扫描结果对话框 -->
|
||||
<el-dialog v-model="scanVisible" title="扫描结果" width="800px" destroy-on-close>
|
||||
<div style="margin-bottom: 12px">
|
||||
<el-tag>共发现 {{ scanResult.total }} 台</el-tag>
|
||||
<el-tag type="warning" style="margin-left: 8px">新设备 {{ scanResult.new }} 台</el-tag>
|
||||
<el-tag v-if="scanResult.duplicates?.length" type="danger" style="margin-left: 8px">
|
||||
重复 MAC {{ scanResult.duplicates.length }} 个
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-table :data="scanResult.devices" max-height="400">
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'online' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'online' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="distance_m" label="距离" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ row.distance_m || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="端口" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.port_id || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="loid" label="LOID" width="80" />
|
||||
<el-table-column prop="model" label="型号" />
|
||||
<el-table-column label="是否新设备" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_new" type="warning" size="small">新发现</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button type="warning" :loading="discovering" @click="discoverOlt">确认入库新设备</el-button>
|
||||
<el-button @click="scanVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重复 MAC 地址对话框 -->
|
||||
<el-dialog v-model="dupVisible" title="重复 MAC 地址" width="800px" destroy-on-close>
|
||||
<div style="margin-bottom: 12px; color: #909399; font-size: 13px">
|
||||
以下 MAC 地址在 OLT 上出现在多个端口,可能是设备更换端口后旧记录未清除。对离线的旧端口执行"清除"可恢复其默认配置。
|
||||
</div>
|
||||
<el-table :data="dupList" max-height="450" empty-text="暂无重复 MAC 记录">
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160" />
|
||||
<el-table-column label="出现的端口" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div v-for="p in row.ports" :key="p.port_id" style="display: inline-flex; align-items: center; margin-right: 8px; margin-bottom: 4px">
|
||||
<el-tag
|
||||
:type="p.status === 'online' ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ p.port_id }} ({{ p.status === 'online' ? '在线' : '离线' }})
|
||||
</el-tag>
|
||||
<el-button
|
||||
v-if="p.status !== 'online'"
|
||||
type="danger"
|
||||
size="small"
|
||||
text
|
||||
:loading="clearingPort === `${row.id}-${p.port_id}`"
|
||||
style="margin-left: 2px; padding: 0 4px"
|
||||
@click="clearPort(row, p.port_id)"
|
||||
>清除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后发现" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString() : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" size="small" text @click="deleteDupRecord(row.id)">忽略</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="dupVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新增设备对话框 -->
|
||||
<el-dialog v-model="newDevVisible" title="新增设备" width="900px" destroy-on-close>
|
||||
<div style="margin-bottom: 12px; color: #909399; font-size: 13px">
|
||||
以下设备是 OLT 扫描新发现的,请补全区域和学校信息后确认入库。
|
||||
</div>
|
||||
<el-table :data="newDevList" max-height="450" empty-text="暂无新增设备">
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160" />
|
||||
<el-table-column prop="port_id" label="端口" width="100" />
|
||||
<el-table-column prop="model" label="型号" width="160" />
|
||||
<el-table-column label="区域 *" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.region" size="small" placeholder="必填" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="学校 *" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.school_name" size="small" placeholder="必填" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="楼宇" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.building" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="房间号" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.room_number" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" text :loading="savingDev === row.id" @click="saveNewDevice(row)">保存</el-button>
|
||||
<el-button type="info" size="small" text @click="dismissNewDevice(row.id)">忽略</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="newDevVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '../utils/request'
|
||||
|
||||
const devices = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const scanVisible = ref(false)
|
||||
const dupVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const selectedDevice = ref({})
|
||||
const scanning = ref(false)
|
||||
const discovering = ref(false)
|
||||
const importing = ref(false)
|
||||
const scanResult = ref({ total: 0, new: 0, devices: [], duplicates: [] })
|
||||
const dupList = ref([])
|
||||
const duplicateCount = ref(0)
|
||||
const clearingPort = ref('')
|
||||
const newDevList = ref([])
|
||||
const newDeviceCount = ref(0)
|
||||
const newDevVisible = ref(false)
|
||||
const savingDev = ref(null)
|
||||
const loopDetecting = ref(false)
|
||||
const loopVisible = ref(false)
|
||||
const loopResults = ref([])
|
||||
const loopStatusMap = ref({}) // olt_id -> loop_interfaces[]
|
||||
const loopDetailVisible = ref(false)
|
||||
const loopDetailInterfaces = ref([])
|
||||
const quickScanning = ref(false)
|
||||
const quickScanVisible = ref(false)
|
||||
const quickScanResult = ref({ total_online: 0, total_offline: 0, total_new: 0, results: [], errors: [] })
|
||||
const form = ref({ ip_address: '', username: '', password: '', slot_command: 'display onu slot', location: '', description: '' })
|
||||
|
||||
const fetchDevices = async () => {
|
||||
const { data } = await request.get('/olt/devices')
|
||||
devices.value = data
|
||||
}
|
||||
|
||||
const fetchDuplicateCount = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/olt/duplicate-macs')
|
||||
duplicateCount.value = data.length
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
isEdit.value = false
|
||||
form.value = { ip_address: '', username: '', password: '', slot_command: 'display onu slot', location: '', description: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openDetail = (row) => {
|
||||
selectedDevice.value = { ...row }
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const openEdit = () => {
|
||||
detailVisible.value = false
|
||||
isEdit.value = true
|
||||
form.value = {
|
||||
ip_address: selectedDevice.value.ip_address,
|
||||
username: selectedDevice.value.username,
|
||||
password: '',
|
||||
slot_command: selectedDevice.value.slot_command || 'display onu slot',
|
||||
location: selectedDevice.value.location || '',
|
||||
description: selectedDevice.value.description || ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveDevice = async () => {
|
||||
const payload = { ...form.value }
|
||||
if (!payload.password) {
|
||||
delete payload.password
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await request.put(`/olt/devices/${form.value.ip_address}`, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await request.post('/olt/devices', payload)
|
||||
ElMessage.success('保存成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchDevices()
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除此 OLT 设备吗?', '确认删除', { type: 'warning' })
|
||||
await request.delete(`/olt/devices/${selectedDevice.value.ip_address}`)
|
||||
ElMessage.success('删除成功')
|
||||
detailVisible.value = false
|
||||
fetchDevices()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.response?.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scanOlt = async () => {
|
||||
scanning.value = true
|
||||
try {
|
||||
const { data } = await request.post(`/check/scan/${selectedDevice.value.id}`)
|
||||
scanResult.value = data
|
||||
detailVisible.value = false
|
||||
scanVisible.value = true
|
||||
fetchDuplicateCount()
|
||||
fetchNewDeviceCount()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '扫描失败')
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const discoverOlt = async () => {
|
||||
discovering.value = true
|
||||
try {
|
||||
const { data } = await request.post(`/check/discover/${selectedDevice.value.id}`)
|
||||
const dupMsg = data.duplicate_macs > 0 ? `,重复 MAC ${data.duplicate_macs} 个` : ''
|
||||
ElMessage.success(`扫描完成:在线 ${data.online},离线 ${data.offline},新入库 ${data.new_discovered} 台${dupMsg}`)
|
||||
scanVisible.value = false
|
||||
detailVisible.value = false
|
||||
fetchDuplicateCount()
|
||||
fetchNewDeviceCount()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '入库失败')
|
||||
} finally {
|
||||
discovering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openDuplicateMacs = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/olt/duplicate-macs')
|
||||
dupList.value = data
|
||||
duplicateCount.value = data.length
|
||||
dupVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('获取重复 MAC 失败')
|
||||
}
|
||||
}
|
||||
|
||||
const clearPort = async (row, portId) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要清除端口 Onu${portId} 的配置吗?此操作将通过 SSH 执行 default 命令,不可撤销。`,
|
||||
'确认清除',
|
||||
{ type: 'warning', confirmButtonText: '确认清除', confirmButtonClass: 'el-button--danger' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
clearingPort.value = `${row.id}-${portId}`
|
||||
try {
|
||||
const { data } = await request.post(`/olt/duplicate-macs/${row.id}/clear-port`, { port_id: portId })
|
||||
ElMessage.success(data.message)
|
||||
if (data.remaining_ports.length === 0) {
|
||||
dupList.value = dupList.value.filter(r => r.id !== row.id)
|
||||
} else {
|
||||
row.ports = data.remaining_ports
|
||||
}
|
||||
duplicateCount.value = dupList.value.length
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '清除失败')
|
||||
} finally {
|
||||
clearingPort.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDupRecord = async (id) => {
|
||||
try {
|
||||
await request.delete(`/olt/duplicate-macs/${id}`)
|
||||
dupList.value = dupList.value.filter(r => r.id !== id)
|
||||
duplicateCount.value = dupList.value.length
|
||||
ElMessage.success('已清除')
|
||||
} catch (error) {
|
||||
ElMessage.error('清除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNewDeviceCount = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/olt/new-devices')
|
||||
newDeviceCount.value = data.length
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const openNewDevices = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/olt/new-devices')
|
||||
newDevList.value = data.map(d => ({ ...d, region: d.region || '', school_name: d.school_name || '', building: d.building || '', room_number: d.room_number || '' }))
|
||||
newDeviceCount.value = data.length
|
||||
newDevVisible.value = true
|
||||
} catch {
|
||||
ElMessage.error('获取新增设备失败')
|
||||
}
|
||||
}
|
||||
|
||||
const saveNewDevice = async (row) => {
|
||||
if (!row.region || !row.school_name) {
|
||||
ElMessage.warning('区域和学校为必填项')
|
||||
return
|
||||
}
|
||||
savingDev.value = row.id
|
||||
try {
|
||||
await request.put(`/olt/new-devices/${row.id}`, {
|
||||
region: row.region,
|
||||
school_name: row.school_name,
|
||||
building: row.building,
|
||||
room_number: row.room_number,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
newDevList.value = newDevList.value.filter(d => d.id !== row.id)
|
||||
newDeviceCount.value = newDevList.value.length
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
savingDev.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const dismissNewDevice = async (id) => {
|
||||
try {
|
||||
await request.delete(`/olt/new-devices/${id}`)
|
||||
newDevList.value = newDevList.value.filter(d => d.id !== id)
|
||||
newDeviceCount.value = newDevList.value.length
|
||||
ElMessage.success('已忽略')
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const runQuickScan = async () => {
|
||||
quickScanning.value = true
|
||||
try {
|
||||
const { data } = await request.post('/olt/quick-scan')
|
||||
quickScanResult.value = data
|
||||
quickScanVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '快速扫描失败')
|
||||
} finally {
|
||||
quickScanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const runLoopbackDetection = async () => {
|
||||
loopDetecting.value = true
|
||||
try {
|
||||
const { data } = await request.post('/olt/loopback-detection')
|
||||
loopResults.value = data
|
||||
// 更新列表中的环路标志
|
||||
const map = {}
|
||||
for (const item of data) {
|
||||
if (item.has_loop && item.loop_interfaces.length > 0) {
|
||||
map[item.olt_id] = item.loop_interfaces
|
||||
}
|
||||
}
|
||||
loopStatusMap.value = map
|
||||
loopVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '环路检测失败')
|
||||
} finally {
|
||||
loopDetecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showLoopDetail = (oltId) => {
|
||||
loopDetailInterfaces.value = loopStatusMap.value[oltId] || []
|
||||
loopDetailVisible.value = true
|
||||
}
|
||||
|
||||
const handleImportFile = async (uploadFile) => {
|
||||
importing.value = true
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile.raw)
|
||||
try {
|
||||
const { data } = await request.post('/olt/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
const failMsg = data.failed?.length ? `,${data.failed.length} 条失败` : ''
|
||||
ElMessage.success(`成功导入 ${data.success} 条${failMsg}`)
|
||||
fetchDevices()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.detail || '导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportSuccess = () => {
|
||||
ElMessage.success('导入成功')
|
||||
fetchDevices()
|
||||
}
|
||||
|
||||
const downloadTemplate = () => {
|
||||
window.open('/api/olt/template', '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDevices()
|
||||
fetchDuplicateCount()
|
||||
fetchNewDeviceCount()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.olt-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 切换到项目根目录
|
||||
os.chdir('/home/v6ole/pyproject/H3ConuMS2')
|
||||
|
||||
# 设置 PYTHONPATH
|
||||
sys.path.insert(0, '/home/v6ole/pyproject/H3ConuMS2/backend')
|
||||
|
||||
# 设置环境变量
|
||||
os.environ['SECRET_KEY'] = 'your-secret-key-change-this'
|
||||
os.environ['DATABASE_URL'] = 'postgresql://H3C_onu_ms:nEHBFFpBsJfiZp3N@10.10.10.14:5432/H3C_onu_ms'
|
||||
os.environ['REDIS_URL'] = 'redis://:redis_c4FFJQ@10.10.10.14:6379'
|
||||
os.environ['CASDOOR_ENDPOINT'] = 'https://casdoor.dhdx.fun'
|
||||
os.environ['CASDOOR_CLIENT_ID'] = '2289912424bcea5f9859'
|
||||
os.environ['CASDOOR_CLIENT_SECRET'] = 'cfa97de106762c8293901a8daef11e04f1b10043'
|
||||
os.environ['CASDOOR_REDIRECT_URL'] = 'http://10.10.10.14:5173/callback'
|
||||
os.environ['CASDOOR_ORG_NAME'] = 'dahua'
|
||||
os.environ['CASDOOR_APP_NAME'] = 'H3ConuMS'
|
||||
os.environ['CASDOOR_CERTIFICATE'] = 'token_jwt_key.pem'
|
||||
|
||||
# 加载 celery app
|
||||
from celery_worker import celery_app
|
||||
|
||||
# 运行 worker
|
||||
if __name__ == '__main__':
|
||||
celery_app.worker_main(['worker', '--loglevel=info'])
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# 快速启动脚本
|
||||
|
||||
echo "=== H3C ONU 设备管理系统 ==="
|
||||
echo ""
|
||||
|
||||
# 检查虚拟环境
|
||||
if [ ! -d "backend/venv" ]; then
|
||||
echo "创建虚拟环境..."
|
||||
cd backend && python3 -m venv venv && cd ..
|
||||
fi
|
||||
|
||||
# 激活虚拟环境
|
||||
source backend/venv/bin/activate
|
||||
|
||||
# 检查依赖
|
||||
if ! python -c "import fastapi" 2>/dev/null; then
|
||||
echo "安装依赖..."
|
||||
pip install -r backend/requirements.txt
|
||||
fi
|
||||
|
||||
# 初始化数据库
|
||||
echo "初始化数据库..."
|
||||
python backend/scripts/init_db.py
|
||||
|
||||
echo ""
|
||||
echo "✅ 初始化完成!"
|
||||
echo ""
|
||||
echo "启动服务:"
|
||||
echo " 后端: cd backend && uvicorn app.main:app --reload"
|
||||
echo " 前端: cd frontend && npm run dev"
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
# 启动开发环境
|
||||
|
||||
echo "启动后端服务..."
|
||||
cd backend
|
||||
source venv/bin/activate 2>/dev/null || python -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
cd /home/v6ole/pyproject/H3ConuMS2
|
||||
|
||||
export PYTHONPATH=/home/v6ole/pyproject/H3ConuMS2/backend
|
||||
|
||||
# 设置环境变量
|
||||
export SECRET_KEY="your-secret-key-change-this"
|
||||
export DATABASE_URL="postgresql://H3C_onu_ms:nEHBFFpBsJfiZp3N@10.10.10.14:5432/H3C_onu_ms"
|
||||
export REDIS_URL="redis://:redis_c4FFJQ@10.10.10.14:6379"
|
||||
export CASDOOR_ENDPOINT="https://casdoor.dhdx.fun"
|
||||
export CASDOOR_CLIENT_ID="2289912424bcea5f9859"
|
||||
export CASDOOR_CLIENT_SECRET="cfa97de106762c8293901a8daef11e04f1b10043"
|
||||
export CASDOOR_REDIRECT_URL="http://10.10.10.14:5173/callback"
|
||||
export CASDOOR_ORG_NAME="dahua"
|
||||
export CASDOOR_APP_NAME="H3ConuMS"
|
||||
export CASDOOR_CERTIFICATE="token_jwt_key.pem"
|
||||
|
||||
exec /home/v6ole/pyproject/H3ConuMS2/backend/venv/bin/celery -A celery_worker.celery worker --loglevel=info
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
# 启动前端服务
|
||||
|
||||
echo "启动前端服务..."
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
@@ -0,0 +1,22 @@
|
||||
# H3C ONU设备下发上网业务步骤
|
||||
1. 先查看需要下发业务的设备MAC在OLT的哪个口下面,需要进入OLT对应的槽位,如slot 1
|
||||
`display onu slot 1 | include 1484-778e-a860`
|
||||
• OLT返回内容
|
||||
`1484-778e-a860 4 12414 Onu1/0/2:4 WA6520H-EGPON/A 106/ Up N/A`
|
||||
2. 进入系统配置
|
||||
`system-view`
|
||||
3. 进入端口设备的端口
|
||||
`interface Onu1/0/2:4`
|
||||
4. 配置ONU
|
||||
```
|
||||
uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
|
||||
port link-type trunk
|
||||
undo port trunk permit vlan 1
|
||||
port trunk permit vlan 2000 3000 to 3010 4094
|
||||
```
|
||||
5. 保存(输入save force后需要稍等一下才会出现Saved the current configuration to mainboard device successfully.)
|
||||
```
|
||||
[16OLT-Onu1/0/2:4]save force
|
||||
Validating file. Please wait...
|
||||
Saved the current configuration to mainboard device successfully.
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user