提交 8967eb0d authored 作者: 陈泽健's avatar 陈泽健

feat(platform): 新增版本号显示与操作日志子模块 + Docker 容器化部署

新增功能:
- 平台版本号:导航栏显示版本号,后端 /api/version 接口
- 操作日志:问题排查/服务监测/服务管理各自独立的操作日志页面
- 审计 API:/api/audit/logs、/api/audit/users、/api/audit/actions/<module>

Docker 容器化:
- Dockerfile 多进程架构(nginx + Flask + supervisord)
- nginx 配置:SPA 回退 + SSE 流式输出支持
- deploy/deploy_docker.py:一键部署脚本
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 248d6328
......@@ -47,3 +47,14 @@ deploy/
# Docker 自身
Dockerfile
docker-compose.yml
# ===== Vue 前端(构建在 Docker 阶段1完成,不需要本地产物) =====
# node_modules 不需要(Docker 内 npm ci 安装)
frontend/node_modules
# 本地 dist 不需要(Docker 内 npm run build 生成)
# 注:保留注释,不实际排除 dist,便于本地测试时复制
# frontend/dist
# 前端构建缓存
frontend/.vite
......@@ -66,4 +66,9 @@ skill/code/web/service_monitor/data/reports/*.json
!skill/code/web/service_monitor/data/reports/.gitkeep
# 本地临时目录(会话工作区,勿入库)
临时目录/
\ No newline at end of file
临时目录/
# Vue 前端
frontend/node_modules/
frontend/dist/
frontend/*.local
\ No newline at end of file
# ============================================================
# Troubleshoot AI Assistant — Docker 镜像
# ============================================================
# 基础镜像:python:3.11-slim(Debian bookworm,含 bash)
# 运行时:Flask 内置 server(单进程,APScheduler 兼容)
# 架构:
# nginx:80 (对外) → Vue SPA / API 反代
# Flask:8088 (容器内) → 后端 API
# supervisord → 管理 nginx + Flask 双进程
#
# 前端构建:本地执行 npm run build,产物 frontend/dist/ 打包进镜像
# 数据持久化:通过 volume 挂载,不写入镜像层
# ============================================================
# ===== 运行时镜像 =====
FROM python:3.11-slim AS base
# 系统依赖:bash(LocalExecutor 执行检测脚本)
# 不装 docker CLI —— 不挂载 docker.sock,避免影响宿主机其他容器
# 系统依赖:bash + nginx + supervisor + curl(健康检查)
RUN apt-get update && apt-get install -y --no-install-recommends \
bash \
nginx \
supervisor \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ---------- 依赖层(利用 Docker 缓存) ----------
# ---------- Python 依赖层(利用 Docker 缓存) ----------
COPY skill/code/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
......@@ -24,27 +31,39 @@ RUN pip install --no-cache-dir -r /app/requirements.txt
COPY skill/code/web/ /app/web/
# 复制知识库 SKILL.md(问题排查助手 prompt 模板)
COPY skill/code/SKILL.md /app/SKILL.md
COPY skill/SKILL.md /app/SKILL.md
# 运行时数据目录(volume 挂载点,容器内不写数据到镜像层)
# ---------- Vue 前端构建产物(本地 npm run build 后直接复制) ----------
COPY frontend/dist/ /data/dist/
# ---------- 运行时数据目录(volume 挂载点) ----------
RUN mkdir -p /app/data \
&& mkdir -p /app/web/service_monitor/data/reports \
&& mkdir -p /app/web/cache \
&& mkdir -p /app/web/logs
&& mkdir -p /app/web/logs \
&& mkdir -p /var/log/supervisor
# 修复 Windows 开发环境产生的 CRLF 行尾(bash 脚本在 Linux 必须是 LF)
RUN find /app/web/service_monitor/assets -name '*.sh' -o -name '*.template' \
| xargs -r sed -i 's/\r$//'
# 环境变量
# ---------- 配置文件 ----------
# nginx 配置
COPY nginx/nginx.conf /etc/nginx/nginx.conf
# supervisord 配置
COPY config/supervisord.conf /etc/supervisor/conf.d/troubleshoot.conf
# ---------- 环境变量 ----------
ENV FLASK_DEBUG=0 \
PYTHONIOENCODING=utf-8 \
TROUBLESHOOT_ROOT=/app \
LANG=C.UTF-8
EXPOSE 8088
# nginx 对外端口
EXPOSE 80
WORKDIR /app/web
# 单进程启动(不用 gunicorn 多 worker,避免 APScheduler 多实例冲突
CMD ["python3", "server.py"]
# supervisord 启动(管理 nginx + Flask 双进程
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/troubleshoot.conf"]
# PRD — Docker 容器化巡检环境隔离问题
> 版本:1.0 | 日期:2026-07-29 | 作者:czj
---
## 1. 问题描述
当前服务监测模块已通过 Docker 容器化部署,巡检脚本在容器内执行。这导致以下问题:
### 1.1 本级巡检(本地目标)问题
| 问题 | 说明 |
|------|------|
| **系统信息不真实** | `uname -a``/proc/*` 读取的是容器内核信息,非宿主机 |
| **CPU/内存数据错误** | 容器有资源限制,读取的 `/proc/cpuinfo``/proc/meminfo` 与宿主机不同 |
| **磁盘信息偏差** | 容器文件系统是挂载的 volume,`df` 看到的是容器视角 |
| **进程列表不完整** | 容器内 `ps aux` 只能看到容器内进程 |
| **网络信息不真实** | 容器有自己的网络命名空间,`ip addr``netstat` 是容器网络 |
### 1.2 远程 SSH 巡检
远程巡检通过 SSH 连接目标服务器执行脚本,**理论上不受影响**。但需要验证:
- SSH 连接从容器内发起是否正常
- SSH 密钥/密码在容器内是否可用
---
## 2. 需求目标
### 方案选择
| 方案 | 优点 | 缺点 |
|------|------|------|
| **A. 宿主机部署** | 本级巡检真实、简单直接 | 需要回退到 systemd/nohup 方式,丢失 Docker 隔离优势 |
| **B. 容器特权模式 + 挂载** | 保留 Docker 部署,本级巡检真实 | 安全风险大,容器逃逸风险,配置复杂 |
| **C. 本级巡检走 SSH 到宿主机** | 保留 Docker 部署,本级数据真实 | 需要配置容器到宿主机的 SSH,略复杂 |
| **D. 本级巡检禁用/标记** | 最简单,避免误导用户 | 功能缺失 |
### 推荐方案:**C. 本级巡检走 SSH 到宿主机**
**实现思路**
1. 本级目标(`host_local`)的巡检,通过 SSH 连接到宿主机执行
2. 宿主机需要配置 SSH 服务,容器通过 SSH 访问
3. 容器内存储宿主机的 SSH 凭据(或使用 SSH 密钥挂载)
---
## 3. 功能规格
### 3.1 本级目标自动检测
当创建 `host_local` 类型目标时,系统自动识别为"本级巡检",并:
- 提示用户配置宿主机 SSH 连接信息
- 或自动使用 Docker 宿主机 IP(通过 `host.docker.internal` 或网关 IP)
### 3.2 巡检执行逻辑调整
```python
def execute_check(target):
if target.get('host') == 'localhost' or target.get('type') == 'local':
# 本级巡检 → SSH 到宿主机执行
host_ip = get_docker_host_ip() # 获取宿主机 IP
ssh_to_host_and_execute(host_ip, scripts)
else:
# 远程巡检 → 正常 SSH 执行
ssh_to_target_and_execute(target['host'], scripts)
```
### 3.3 宿主机 IP 获取方式
**方式一**:Docker 网关 IP(Linux)
```bash
# 容器内执行
ip route | grep default | awk '{print $3}'
# 返回如 172.17.0.1(Docker 网关)
```
**方式二**`host.docker.internal`(Docker Desktop / 部分环境)
```bash
# 需要容器启动时添加参数
docker run --add-host=host.docker.internal:host-gateway ...
```
### 3.4 宿主机 SSH 配置
需要在宿主机上:
1. 确保 SSH 服务运行(`sshd`
2. 创建巡检专用用户或使用现有用户
3. 配置 sudo 免密执行巡检脚本(可选)
---
## 4. 后端实现
### 4.1 修改目标服务
**文件**`skill/code/web/service_monitor/services/target_service.py`
```python
def create_target(data):
# 自动检测本级目标
if data.get('host') in ('localhost', '127.0.0.1', '::1'):
data['type'] = 'local'
data['requires_host_ssh'] = True # 标记需要宿主机 SSH
```
### 4.2 修改执行器
**文件**`skill/code/web/service_monitor/utils/executor.py`
```python
class LocalExecutor:
"""本级巡检执行器 - 通过 SSH 到宿主机"""
def __init__(self):
self.host_ip = self._get_docker_host_ip()
self.ssh_client = None
def _get_docker_host_ip(self):
"""获取 Docker 宿主机 IP"""
import subprocess
result = subprocess.run(
['ip', 'route'],
capture_output=True, text=True
)
for line in result.stdout.split('\n'):
if 'default' in line:
return line.split()[2] # 网关 IP
return 'host.docker.internal'
def execute(self, script_path):
"""通过 SSH 到宿主机执行脚本"""
return ssh_execute(
host=self.host_ip,
username='monitor', # 宿主机巡检用户
key_or_password=..., # 从目标配置或环境变量读取
script=script_path
)
```
### 4.3 环境变量配置
`.env` 中添加:
```
# 本级巡检宿主机 SSH 配置
LOCAL_SSH_HOST=172.17.0.1 # 或 host.docker.internal
LOCAL_SSH_USER=monitor
LOCAL_SSH_KEY=/app/keys/host_key # 挂载的 SSH 密钥
```
---
## 5. 前端调整
### 5.1 目标创建页面提示
当用户填写 `localhost``127.0.0.1` 时,显示提示:
> 检测到本级目标,巡检将通过 SSH 连接到宿主机执行。请确保:
> 1. 宿主机 SSH 服务已启动
> 2. 已配置 SSH 凭据(见环境变量 LOCAL_SSH_*)
### 5.2 目标列表标识
本级目标显示特殊图标或标签,区分"本级(宿主机)"和"远程"。
---
## 6. 部署调整
### 6.1 Docker 启动参数
```bash
docker run -d --name troubleshoot \
--add-host=host.docker.internal:host-gateway \ # 添加宿主机映射
-v /path/to/ssh_key:/app/keys/host_key:ro \ # 挂载 SSH 密钥
...
```
### 6.2 宿主机配置脚本
提供一键配置脚本,在宿主机上:
- 创建巡检用户 `monitor`
- 配置 sudo 免密执行
- 生成 SSH 密钥对
---
## 7. 临时方案(快速修复)
如果暂时不做完整改造,建议:
1. **禁用本级目标创建**:前端隐藏本级目标选项
2. **目标列表标记**:已存在的本级目标显示警告:"容器化部署下本级巡检数据可能不准确"
3. **巡检报告提示**:本级巡检报告中添加说明
---
## 8. 实施优先级
| 优先级 | 任务 | 工作量 |
|--------|------|--------|
| **P0** | 本级目标标记 + 警告提示 | 0.5 天 |
| **P1** | 本级巡检 SSH 到宿主机 | 1 天 |
| **P2** | 前端优化 + 一键配置脚本 | 0.5 天 |
| **P3** | 文档更新 + 测试验证 | 0.5 天 |
# PRD — 平台版本号与操作日志子模块
> 版本:1.0 | 日期:2026-07-29 | 作者:czj
---
## 1. 背景
当前运行维护平台已完成 Vue 前端迁移,包含三个功能模块:
- **问题排查助手**:问题搜索、AI 分析
- **服务监测**:目标管理、巡检执行、报告查看、定时任务、通知配置
- **服务管理**:服务授权、服务升级、服务信息
平台存在以下缺失:
1. **无版本号显示**:用户无法直观了解当前系统版本
2. **操作日志分散**:后端已有 `log_audit()` 记录操作,但前端无可视化界面查看各模块的操作历史
## 2. 需求目标
### 2.1 平台版本号
- 在平台导航栏右侧显示当前版本号
- 版本号来源:前端 `package.json``version` 字段
- 支持版本号点击跳转到更新日志(可选)
### 2.2 操作日志子模块
为三个模块各自增加「操作日志」页面:
| 模块 | 路由 | 记录的操作 |
|------|------|------------|
| 问题排查 | `/troubleshoot/logs` | 搜索、分析、导出、入库 |
| 服务监测 | `/service-monitor/logs` | 目标创建/编辑/删除、巡检执行、报告导出、定时任务配置、通知配置变更 |
| 服务管理 | `/service-manage/logs` | 授权变更、升级操作、信息查询 |
**操作日志字段**
- 操作时间
- 操作用户
- 操作类型(搜索/分析/导出/入库/创建/编辑/删除/执行等)
- 操作对象(目标名称/报告ID/关键字等)
- 操作详情(JSON 展开,可选)
- IP 地址
## 3. 功能规格
### 3.1 版本号显示
**位置**:导航栏右侧,用户名左侧
**样式**
```
运行维护平台 v0.1.0 admin [退出]
```
**交互**
- 版本号以小标签形式显示(类似 GitHub release tag)
- 鼠标悬停显示完整版本信息(如构建时间)
- 点击跳转到 `/changelog` 页面或弹出更新日志对话框(P2 优先级)
### 3.2 操作日志页面
**通用布局**(三个模块一致):
```
┌─────────────────────────────────────────────────────────────┐
│ 操作日志 [筛选] [刷新] │
├─────────────────────────────────────────────────────────────┤
│ 时间范围: [今天 ▼] 操作类型: [全部 ▼] 用户: [全部 ▼] │
├─────────────────────────────────────────────────────────────┤
│ 操作时间 │ 用户 │ 操作类型 │ 操作对象 │ IP │
├─────────────────────────────────────────────────────────────┤
│ 2026-07-29 10:30 │ admin │ 搜索 │ 数据库连接 │ 192... │
│ 2026-07-29 10:25 │ admin │ 分析 │ 服务启动慢 │ 192... │
│ ... │
├─────────────────────────────────────────────────────────────┤
│ < 1 2 3 4 5 ... 10 > │
└─────────────────────────────────────────────────────────────┘
```
**筛选条件**
- 时间范围:今天 / 最近7天 / 最近30天 / 自定义
- 操作类型:下拉选择(根据模块动态显示)
- 操作用户:下拉选择(从用户列表获取)
**表格列**
| 列名 | 说明 | 宽度 |
|------|------|------|
| 操作时间 | `YYYY-MM-DD HH:mm` 格式 | 150px |
| 用户 | 操作人用户名 | 100px |
| 操作类型 | 搜索/分析/导出/入库/创建/编辑/删除/执行 | 100px |
| 操作对象 | 简短描述(可点击展开详情) | 自适应 |
| IP | 客户端 IP 地址 | 120px |
| 操作 | 查看详情按钮 | 80px |
**详情弹窗**
点击「查看详情」按钮,弹出对话框展示完整操作记录(JSON 格式化显示)。
### 3.3 后端 API
#### 新增接口
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/version` | GET | 获取平台版本号 |
| `/api/audit/logs` | GET | 分页查询操作日志 |
| `/api/audit/users` | GET | 获取有操作记录的用户列表 |
#### `/api/version` 响应
```json
{
"version": "0.1.0",
"buildTime": "2026-07-28T15:30:00Z",
"name": "troubleshoot-frontend"
}
```
#### `/api/audit/logs` 请求参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| module | string | 是 | 模块名:troubleshoot/monitor/manage |
| page | int | 否 | 页码,默认 1 |
| pageSize | int | 否 | 每页条数,默认 20 |
| startTime | string | 否 | 开始时间 ISO 格式 |
| endTime | string | 否 | 结束时间 ISO 格式 |
| actionType | string | 否 | 操作类型筛选 |
| user | string | 否 | 用户筛选 |
#### `/api/audit/logs` 响应
```json
{
"success": true,
"data": {
"total": 150,
"page": 1,
"pageSize": 20,
"items": [
{
"id": 1,
"timestamp": "2026-07-29T10:30:00Z",
"user": "admin",
"module": "troubleshoot",
"action": "search",
"target": "数据库连接失败",
"ip": "192.168.5.100",
"details": {"query": "数据库连接", "results": 5}
}
]
}
}
```
## 4. 数据模型
### 4.1 审计日志存储
**现有方式**:追加写入 `web/audit.log` 文件,每行一条 JSON 记录。
**优化方案**
- 保持文件追加方式(简单可靠)
- 新增模块字段 `module` 用于区分
- 新增字段:`id`(自增)、`target`(操作对象摘要)
**日志记录示例**
```json
{
"id": 1001,
"timestamp": "2026-07-29T10:30:00Z",
"user": "admin",
"module": "troubleshoot",
"action": "search",
"target": "数据库连接失败",
"ip": "192.168.5.100",
"details": {...}
}
```
### 4.2 模块操作类型定义
| 模块 | 操作类型 | action 值 |
|------|----------|-----------|
| troubleshoot | 搜索 | search |
| troubleshoot | 分析 | analyze |
| troubleshoot | 导出 | export |
| troubleshoot | 入库 | submit |
| monitor | 创建目标 | create_target |
| monitor | 编辑目标 | edit_target |
| monitor | 删除目标 | delete_target |
| monitor | 执行巡检 | run_check |
| monitor | 导出报告 | export_report |
| monitor | 创建定时任务 | create_schedule |
| monitor | 编辑定时任务 | edit_schedule |
| monitor | 删除定时任务 | delete_schedule |
| monitor | 配置通知 | config_notification |
| manage | 授权变更 | auth_change |
| manage | 升级操作 | upgrade |
| manage | 信息查询 | query_info |
## 5. 非功能需求
| 项目 | 要求 |
|------|------|
| 性能 | 日志查询响应时间 < 500ms(10000 条以内) |
| 兼容性 | Chrome 90+、Edge 90+、Firefox 88+、移动端响应式 |
| 安全 | 仅登录用户可查看日志,管理员可查看所有用户日志 |
| 存储 | 日志文件按月归档,保留 6 个月 |
## 6. 实现优先级
### P0(核心功能)
- [ ] 版本号显示
- [ ] 操作日志 API(`/api/audit/logs`
- [ ] 问题排查模块操作日志页面
### P1(完整功能)
- [ ] 服务监测模块操作日志页面
- [ ] 服务管理模块操作日志页面
- [ ] 日志详情弹窗
- [ ] 筛选功能
### P2(增强功能)
- [ ] 版本号点击显示更新日志
- [ ] 日志导出功能
- [ ] 日志统计图表
## 7. 验收标准
1. 导航栏正确显示版本号(与 package.json 一致)
2. 三个模块均能访问操作日志页面
3. 操作日志实时记录用户操作(搜索、分析、导出等)
4. 日志筛选功能正常工作
5. 详情弹窗正确展示完整日志记录
6. 移动端响应式布局正常
## 8. 风险与依赖
| 风险 | 缓解措施 |
|------|----------|
| 日志文件过大影响性能 | 按月归档 + 分页查询 |
| 版本号更新遗漏 | 构建脚本自动注入 |
| 操作类型遗漏 | 后端统一常量定义 |
# 执行计划 — Docker 容器巡检环境隔离问题修复
> 版本:1.0 | 日期:2026-07-29 | 关联 PRD:`PRD_Docker容器巡检环境隔离问题.md`
---
## 执行概览
| 阶段 | 任务 | 预估工时 | 状态 |
|------|------|----------|------|
| 阶段一 | 本级目标标记 + 前端警告提示 | 0.5h | 待开始 |
| 阶段二 | 本级巡检 SSH 到宿主机执行 | 2h | 待开始 |
| 阶段三 | Docker 部署配置调整 | 0.5h | 待开始 |
| 阶段四 | 测试验证 | 0.5h | 待开始 |
---
## 阶段一:本级目标标记 + 前端警告提示(P0)
### 1.1 目标服务增加类型判断
**文件**`skill/code/web/service_monitor/services/target_service.py`
```python
def create_target(data):
"""创建目标时自动判断是否为本级"""
host = data.get('host', '').strip()
# 本级目标判断
if host in ('localhost', '127.0.0.1', '::1', 'host.docker.internal'):
data['is_local'] = True
data['local_type'] = 'containerized' # 标记容器化部署
else:
data['is_local'] = False
# 原有逻辑...
```
### 1.2 目标列表返回警告信息
**文件**`skill/code/web/service_monitor/services/target_service.py`
```python
def list_targets(role='admin'):
targets = _load_targets()
for t in targets:
# 本级目标添加警告
if t.get('is_local') and t.get('local_type') == 'containerized':
t['warning'] = '容器化部署下本级巡检数据可能不准确,建议配置宿主机 SSH'
return targets
```
### 1.3 前端目标列表显示警告
**文件**`frontend/src/views/service-monitor/Targets.vue``Index.vue`
```vue
<template v-if="target.warning">
<el-alert
:title="target.warning"
type="warning"
:closable="false"
show-icon
/>
</template>
```
### 1.4 前端目标创建页面提示
**文件**`frontend/src/views/service-monitor/Run.vue` 或目标创建表单
当用户输入 `localhost``127.0.0.1` 时显示提示。
---
## 阶段二:本级巡检 SSH 到宿主机执行(P1)
### 2.1 获取 Docker 宿主机 IP
**文件**`skill/code/web/service_monitor/utils/executor.py`
新增函数:
```python
def get_docker_host_ip():
"""获取 Docker 宿主机 IP
优先级:
1. 环境变量 DOCKER_HOST_IP
2. 从 ip route 解析网关 IP
3. 回退到 host.docker.internal
"""
import os
import subprocess
# 1. 环境变量
ip = os.environ.get('DOCKER_HOST_IP')
if ip:
return ip
# 2. ip route 解析
try:
result = subprocess.run(
['ip', 'route'],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.split('\n'):
if 'default' in line:
parts = line.split()
if len(parts) >= 3:
return parts[2] # 网关 IP
except Exception:
pass
# 3. 回退
return 'host.docker.internal'
```
### 2.2 修改巡检执行器
**文件**`skill/code/web/service_monitor/utils/executor.py`
修改 `LocalExecutor` 类:
```python
class LocalExecutor:
"""本级巡检执行器 - 通过 SSH 到宿主机"""
def __init__(self):
self.host_ip = get_docker_host_ip()
def run_script(self, script_path, timeout=60):
"""执行巡检脚本
本级目标通过 SSH 到宿主机执行
"""
import os
from .executor import SSHExecutor
# 从环境变量获取宿主机 SSH 配置
ssh_user = os.environ.get('LOCAL_SSH_USER', 'root')
ssh_key = os.environ.get('LOCAL_SSH_KEY', '')
ssh_password = os.environ.get('LOCAL_SSH_PASSWORD', '')
if ssh_key:
executor = SSHExecutor(
host=self.host_ip,
username=ssh_user,
key_filename=ssh_key
)
else:
executor = SSHExecutor(
host=self.host_ip,
username=ssh_user,
password=ssh_password
)
return executor.run_script(script_path, timeout)
```
### 2.3 环境变量配置
**文件**`.env`(服务器上)
```bash
# 本级巡检宿主机 SSH 配置
LOCAL_SSH_USER=root
LOCAL_SSH_PASSWORD=Ubains@123
# 或使用密钥
# LOCAL_SSH_KEY=/app/keys/host_key
```
---
## 阶段三:Docker 部署配置调整
### 3.1 Dockerfile 无需修改
SSH 客户端(paramiko)已在 requirements.txt 中。
### 3.2 Docker 启动参数调整
**文件**`deploy/deploy_docker.py` 或手动执行
```bash
docker run -d --name troubleshoot \
--add-host=host.docker.internal:host-gateway \
-e LOCAL_SSH_USER=root \
-e LOCAL_SSH_PASSWORD=Ubains@123 \
-p 8088:80 \
...
```
### 3.3 docker-compose.yml 调整
```yaml
version: '3.8'
services:
troubleshoot:
image: troubleshoot:latest
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- LOCAL_SSH_USER=root
- LOCAL_SSH_PASSWORD=${LOCAL_SSH_PASSWORD}
# ...
```
---
## 阶段四:测试验证
### 4.1 本级巡检测试
```bash
# 在容器内测试
docker exec -it troubleshoot bash
# 测试宿主机 IP 解析
python3 -c "
from service_monitor.utils.executor import get_docker_host_ip
print(get_docker_host_ip())
"
# 测试 SSH 连接宿主机
python3 -c "
import os
from service_monitor.utils.executor import LocalExecutor
executor = LocalExecutor()
print(executor.run_script('/app/web/service_monitor/assets/system/01_system_basic.sh'))
"
```
### 4.2 远程巡检测试
确保远程 SSH 巡检仍然正常工作。
### 4.3 前端测试
1. 本级目标显示警告
2. 本级巡检执行成功
3. 巡检报告数据为宿主机数据
---
## 文件变更清单
### 修改文件
| 文件 | 修改内容 |
|------|----------|
| `skill/code/web/service_monitor/services/target_service.py` | 本级目标标记 + 警告信息 |
| `skill/code/web/service_monitor/utils/executor.py` | 新增 `get_docker_host_ip()` + 修改 `LocalExecutor` |
| `frontend/src/views/service-monitor/Targets.vue` | 显示警告 |
| `frontend/src/views/service-monitor/Index.vue` | 显示警告 |
| `docker-compose.yml` | 添加 extra_hosts + 环境变量 |
### 新增文件
---
## 执行顺序
```
阶段一(标记警告)→ 阶段二(SSH 执行)→ 阶段三(Docker 配置)→ 阶段四(测试)
```
**建议**
1. 先完成阶段一的快速修复(前端警告),让用户知道问题存在
2. 再实现阶段二的完整方案(SSH 到宿主机)
3. 最后更新部署配置并验证
\ No newline at end of file
此差异已折叠。
# ============================================================
# Troubleshoot AI Assistant — supervisord 配置
# ============================================================
# 用途:单容器内管理 nginx + Flask 双进程
#
# 启动命令:
# supervisord -c /etc/supervisor/supervisord.conf
#
# 注意:
# - nodaemon=true 让 supervisord 在前台运行(容器必须)
# - Flask 单进程启动(APScheduler 兼容)
# ============================================================
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
childlogdir=/var/log/supervisor
loglevel=info
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/nginx.log
stderr_logfile=/var/log/supervisor/nginx-error.log
priority=10
[program:flask]
command=python3 server.py
directory=/app/web
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/flask.log
stderr_logfile=/var/log/supervisor/flask-error.log
environment=FLASK_DEBUG="0",PYTHONIOENCODING="utf-8",TROUBLESHOOT_ROOT="/app"
priority=20
stopwaitsecs=30
\ No newline at end of file
# -*- coding: utf-8 -*-
"""
deploy_docker.py - Docker 部署到 192.168.5.60 服务器
部署目录: /data/third_party/monitor-platform
"""
import paramiko
import os
import time
from datetime import datetime
# SSH 配置
HOST = os.environ.get('SSH_HOST', '192.168.5.60')
USER = os.environ.get('SSH_USER', 'ubains')
PASSWORD = os.environ.get('SSH_PASSWORD', '')
REMOTE_BASE = '/data/third_party/monitor-platform'
if not PASSWORD:
print("错误:SSH_PASSWORD 环境变量未设置")
print("用法: SSH_PASSWORD='xxx' python deploy_docker.py")
exit(1)
# 本地仓库根目录
REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
# 需要上传的目录(相对于仓库根目录)
DIRS_TO_UPLOAD = [
('skill/code/web', 'skill/code/web'),
('skill/code/requirements.txt', 'skill/code/requirements.txt'),
('skill/SKILL.md', 'skill/SKILL.md'),
('frontend/dist', 'frontend/dist'),
('nginx', 'nginx'),
('config', 'config'),
]
# 需要上传的单个文件
FILES_TO_UPLOAD = [
('Dockerfile', 'Dockerfile'),
('docker-compose.yml', 'docker-compose.yml'),
('.env', '.env'),
('deploy/搜索索引.json', 'data/搜索索引.json'),
]
# 排除目录
_EXCLUDE_DIRS = {'__pycache__', 'tests', 'data', '.pytest_cache', 'node_modules', '.git'}
_EXCLUDE_SUFFIXES = ('.pyc', '.pyo', '.log')
def _upload_dir_recursive(sftp, ssh, local_dir, remote_dir):
"""递归上传目录"""
# 确保远程目录存在
ssh.exec_command(f'mkdir -p "{remote_dir}"')[1].channel.recv_exit_status()
for fname in os.listdir(local_dir):
if fname in _EXCLUDE_DIRS:
continue
if fname.endswith(_EXCLUDE_SUFFIXES):
continue
lpath = os.path.join(local_dir, fname)
rpath = remote_dir + "/" + fname
if os.path.isfile(lpath):
print(f" Upload: {os.path.relpath(lpath, REPO_ROOT)}")
sftp.put(lpath, rpath)
elif os.path.isdir(lpath):
_upload_dir_recursive(sftp, ssh, lpath, rpath)
def deploy():
print("=" * 60)
print(" Troubleshoot - Docker Deploy")
print("=" * 60)
print(f"Server: {HOST}")
print(f"User: {USER}")
print(f"Remote: {REMOTE_BASE}")
print("=" * 60)
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
print("\n[1/5] Connecting...")
ssh.connect(HOST, username=USER, password=PASSWORD)
print("[OK] Connected")
print("\n[2/5] Preparing directories...")
# 确保目标目录存在
ssh.exec_command(f'sudo mkdir -p {REMOTE_BASE}')[1].channel.recv_exit_status()
ssh.exec_command(f'sudo chown -R ubains:ubains {REMOTE_BASE}')[1].channel.recv_exit_status()
# 创建必要的子目录
for _, remote_rel in DIRS_TO_UPLOAD:
remote_dir = f"{REMOTE_BASE}/{remote_rel}"
ssh.exec_command(f'mkdir -p "{remote_dir}"')[1].channel.recv_exit_status()
print("[OK] Directories prepared")
print("\n[3/5] Uploading files...")
sftp = ssh.open_sftp()
# 上传目录
for local_rel, remote_rel in DIRS_TO_UPLOAD:
local_path = os.path.join(REPO_ROOT, local_rel)
remote_path = f"{REMOTE_BASE}/{remote_rel}"
if not os.path.exists(local_path):
print(f" [SKIP] {local_rel} (not found)")
continue
if os.path.isfile(local_path):
print(f" Upload: {local_rel}")
sftp.put(local_path, remote_path)
else:
print(f" Upload dir: {local_rel}/")
_upload_dir_recursive(sftp, ssh, local_path, remote_path)
# 上传单个文件
for local_rel, remote_rel in FILES_TO_UPLOAD:
local_path = os.path.join(REPO_ROOT, local_rel)
remote_path = f"{REMOTE_BASE}/{remote_rel}"
if not os.path.exists(local_path):
print(f" [SKIP] {local_rel} (not found)")
continue
# 确保父目录存在
parent_dir = os.path.dirname(remote_path)
ssh.exec_command(f'mkdir -p "{parent_dir}"')[1].channel.recv_exit_status()
print(f" Upload: {local_rel}")
sftp.put(local_path, remote_path)
sftp.close()
print("[OK] Files uploaded")
print("\n[4/5] Building Docker image...")
stdin, stdout, stderr = ssh.exec_command(f'cd {REMOTE_BASE} && sudo docker build -t troubleshoot:latest . 2>&1')
build_output = stdout.read().decode()
if 'Successfully tagged' in build_output:
print("[OK] Image built successfully")
else:
print(build_output[-500:] if len(build_output) > 500 else build_output)
# 检查是否只是警告
if 'ERROR' in build_output:
print("[FAIL] Build failed")
return
print("\n[5/5] Starting container...")
# 停止旧容器
stdin, stdout, stderr = ssh.exec_command('sudo docker stop troubleshoot 2>/dev/null; sudo docker rm troubleshoot 2>/dev/null; echo done')
stdout.read()
# 启动新容器
run_cmd = f'''sudo docker run -d --name troubleshoot --restart unless-stopped \\
-p 8088:80 \\
-v {REMOTE_BASE}/monitor-data:/app/web/service_monitor/data \\
-v {REMOTE_BASE}/data/users.json:/app/web/users.json \\
-v {REMOTE_BASE}/data/搜索索引.json:/app/data/搜索索引.json:ro \\
-v {REMOTE_BASE}/frontend/dist:/data/dist \\
-v {REMOTE_BASE}/logs:/app/web/logs \\
--env-file {REMOTE_BASE}/.env \\
troubleshoot:latest'''
stdin, stdout, stderr = ssh.exec_command(run_cmd)
container_id = stdout.read().decode().strip()
err = stderr.read().decode()
if err:
print(f"Error: {err}")
if container_id:
print(f"[OK] Container started: {container_id[:12]}")
else:
print("[FAIL] Container start failed")
return
print("\n[6/6] Verifying...")
time.sleep(5)
stdin, stdout, stderr = ssh.exec_command('curl -s http://localhost:8088/api/health')
result = stdout.read().decode()
if result and '"status":"ok"' in result:
print("[OK] Service is running")
print(f"\nHealth check:")
print(result[:400])
else:
print("[FAIL] Service may not be running")
stdin, stdout, stderr = ssh.exec_command('sudo docker logs troubleshoot --tail 30')
print(stdout.read().decode())
print("\n" + "=" * 60)
print(" Deployment Complete!")
print("=" * 60)
print(f"URL: http://{HOST}:8088")
print(f"Health: http://{HOST}:8088/api/health")
print(f"Deploy dir: {REMOTE_BASE}")
print("=" * 60)
except Exception as e:
print(f"\n[ERROR] {e}")
import traceback
traceback.print_exc()
finally:
ssh.close()
if __name__ == '__main__':
deploy()
\ No newline at end of file
......@@ -6,10 +6,16 @@
# docker compose down # 停止
# docker compose up -d --build # 重新构建并启动
#
# 架构:
# nginx:80 (对外) → Vue SPA + API 反向代理
# Flask:8088 (容器内) → 后端 API
# supervisord → 管理 nginx + Flask 双进程
#
# 注意:
# - 单容器部署,不用 gunicorn 多 worker(APScheduler 不兼容多进程)
# - 不挂载 docker.sock(避免影响宿主机其他容器)
# - 资源限制防止单容器抢占宿主机资源
# - 前端 dist 通过 volume 挂载,更新前端无需重启容器
# ============================================================
services:
......@@ -21,7 +27,8 @@ services:
container_name: troubleshoot
restart: unless-stopped
ports:
- "8088:8088"
- "8088:80" # nginx 对外端口(映射到 8088,保持与旧服务一致)
# 8088 仅容器内访问,不对外暴露
volumes:
# 服务监测数据(targets/schedules/reports/notifications)
- monitor-data:/app/web/service_monitor/data
......@@ -30,6 +37,8 @@ services:
# 知识库索引(只读)
- /opt/troubleshoot/data/搜索索引.json:/app/data/搜索索引.json:ro
- /opt/troubleshoot/data/搜索向量.json:/app/data/搜索向量.json:ro
# 前端 dist(热更新:替换此目录即可,无需重启容器)
- /opt/troubleshoot/dist:/data/dist
# 缓存目录
- cache-data:/app/web/cache
# 日志
......@@ -52,9 +61,9 @@ services:
reservations:
memory: 256M
cpus: '0.25'
# 健康检查
# 健康检查(通过 nginx 反代到 Flask)
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8088/api/health')"]
test: ["CMD", "curl", "-sf", "http://localhost/api/health"]
interval: 30s
timeout: 10s
retries: 3
......
# Vite proxy 会将 /api 请求转发到 Flask,所以 baseURL 留空
VITE_API_BASE_URL=
VITE_FLASK_PORT=8088
# 生产环境:前端与 Flask 同域部署(nginx 反代),baseURL 留空
VITE_API_BASE_URL=
#!/bin/bash
# ============================================================
# Vue 前端构建脚本
# ============================================================
# 用途:
# 本地构建前端产物,用于手动部署到 5.60 的 /opt/troubleshoot/dist/
#
# 使用方式:
# ./build.sh # 构建生产版本
# ./build.sh --preview # 构建后本地预览
#
# 部署到 5.60(容器已运行时):
# scp -r dist/* user@192.168.5.60:/opt/troubleshoot/dist/
# # 或者:docker exec troubleshoot nginx -s reload
# ============================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DIST_DIR="${SCRIPT_DIR}/dist"
# 颜色输出
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
# ---------- 检查 Node ----------
if ! command -v node &> /dev/null; then
echo "[ERROR] Node.js 未安装,请先安装: https://nodejs.org/"
exit 1
fi
if ! command -v npm &> /dev/null; then
echo "[ERROR] npm 未安装"
exit 1
fi
info "Node: $(node --version)"
info "npm: $(npm --version)"
# ---------- 安装依赖 ----------
info "安装依赖..."
cd "${SCRIPT_DIR}"
npm ci --registry=https://registry.npmmirror.com
# ---------- 类型检查 ----------
info "TypeScript 类型检查..."
if npx vue-tsc --noEmit; then
info "类型检查通过"
else
warn "类型检查有错误,继续构建..."
fi
# ---------- 构建 ----------
info "构建生产版本..."
npm run build
# ---------- 输出 ----------
if [ -f "${DIST_DIR}/index.html" ]; then
info "构建完成!"
info "产物目录: ${DIST_DIR}"
info ""
info "文件列表:"
ls -lh "${DIST_DIR}/"
ls -lh "${DIST_DIR}/assets/" 2>/dev/null || true
info ""
info "部署到 5.60:"
info " scp -r ${DIST_DIR}/* user@192.168.5.60:/opt/troubleshoot/dist/"
info " # 或在容器内: docker exec troubleshoot nginx -s reload"
else
echo "[ERROR] 构建失败,dist/index.html 不存在"
exit 1
fi
# ---------- 预览 ----------
if [ "${1:-}" = "--preview" ]; then
info "启动本地预览..."
npx vite preview --port 4173
fi
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_API_BASE_URL: string
readonly VITE_FLASK_PORT: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>运行维护平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
此差异已折叠。
{
"name": "troubleshoot-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"dompurify": "^3.4.12",
"echarts": "^5.5.1",
"element-plus": "^2.9.1",
"marked": "^18.0.7",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/marked": "^5.0.2",
"@types/node": "^26.1.2",
"@vitejs/plugin-vue": "^5.2.1",
"sass": "^1.83.0",
"typescript": "~5.6.3",
"unplugin-auto-import": "^0.19.0",
"unplugin-vue-components": "^0.28.0",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#2563eb"/>
<text x="16" y="22" text-anchor="middle" font-size="18" font-family="Arial" fill="white" font-weight="bold">T</text>
</svg>
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style scoped>
</style>
/**
* 认证 API
*/
import http from '@/utils/http'
import type { LoginRequest, LoginResponse, UserInfo } from '@/types/api'
/** 登录 */
export async function login(data: LoginRequest): Promise<LoginResponse> {
const res = await http.post<LoginResponse>('/login', data)
return res.data
}
/** 登出 */
export async function logout(): Promise<{ success: boolean }> {
const res = await http.post<{ success: boolean }>('/logout')
return res.data
}
/** 获取当前用户信息 */
export async function getUserInfo(): Promise<{ success: boolean; user: UserInfo }> {
const res = await http.get<{ success: boolean; user: UserInfo }>('/api/user/info')
return res.data
}
\ No newline at end of file
/**
* 服务监测 API 索引
*/
export * as targetApi from './target'
export * as reportApi from './report'
export * as scheduleApi from './schedule'
export * as statisticsApi from './statistics'
export * as notificationApi from './notification'
\ No newline at end of file
/**
* 服务监测 - 通知配置 API
*/
import http from '@/utils/http'
import type { NotificationConfig, NotificationConfigResponse } from '@/types/service-monitor'
/** 获取通知配置 */
export async function getNotificationConfig(): Promise<NotificationConfigResponse> {
const res = await http.get<NotificationConfigResponse>('/api/service-monitor/notification')
return res.data
}
/** 保存通知配置 */
export async function saveNotificationConfig(
data: NotificationConfig
): Promise<NotificationConfigResponse> {
const res = await http.put<NotificationConfigResponse>(
'/api/service-monitor/notification',
data
)
return res.data
}
/** 测试邮件通知 */
export async function testEmail(): Promise<{ success: boolean; message: string }> {
const res = await http.post<{ success: boolean; message: string }>(
'/api/service-monitor/notification/test-email'
)
return res.data
}
/** 测试钉钉通知 */
export async function testDingTalk(): Promise<{ success: boolean; message: string }> {
const res = await http.post<{ success: boolean; message: string }>(
'/api/service-monitor/notification/test-dingtalk'
)
return res.data
}
/** 测试企业微信通知 */
export async function testWeCom(): Promise<{ success: boolean; message: string }> {
const res = await http.post<{ success: boolean; message: string }>(
'/api/service-monitor/notification/test-wecom'
)
return res.data
}
\ No newline at end of file
/**
* 服务监测 - 报告管理 API
*/
import http from '@/utils/http'
import { createSSE } from '@/utils/sse'
import type {
ReportSummary,
ReportDetail,
BatchDeleteReportsRequest,
RunStatusResponse,
InspectProgressEvent,
CompareResult,
} from '@/types/service-monitor'
/** 获取报告列表 */
export async function getReports(
params?: { target_id?: string }
): Promise<{ success: boolean; reports: ReportSummary[] }> {
const res = await http.get<{ success: boolean; reports: ReportSummary[] }>(
'/api/service-monitor/reports',
{ params }
)
return res.data
}
/** 获取报告详情 */
export async function getReport(reportId: string): Promise<{ success: boolean; report: ReportDetail }> {
const res = await http.get<{ success: boolean; report: ReportDetail }>(
`/api/service-monitor/reports/${reportId}`
)
return res.data
}
/** 删除报告 */
export async function deleteReport(reportId: string): Promise<{ success: boolean }> {
const res = await http.delete<{ success: boolean }>(`/api/service-monitor/reports/${reportId}`)
return res.data
}
/** 批量删除报告 */
export async function batchDeleteReports(
data: BatchDeleteReportsRequest
): Promise<{ success: boolean; deleted: number }> {
const res = await http.delete<{ success: boolean; deleted: number }>(
'/api/service-monitor/reports/batch',
{ data }
)
return res.data
}
/** 导出报告 */
export function getReportExportUrl(
reportId: string,
format: 'md' | 'json' | 'excel' | 'pdf' = 'md',
token?: string
): string {
let url = `/api/service-monitor/reports/${reportId}/export?format=${format}`
if (token) {
url += `&token=${token}`
}
return url
}
/** 对比报告 */
export async function compareReports(
reportIdA: string,
reportIdB: string
): Promise<{ success: boolean; data: CompareResult }> {
const res = await http.get<{ success: boolean; data: CompareResult }>(
`/api/service-monitor/reports/${reportIdA}/compare/${reportIdB}`
)
return res.data
}
/** 导出对比报告 URL */
export function getCompareExportUrl(
reportIdA: string,
reportIdB: string,
format: 'md' | 'html' = 'md'
): string {
return `/api/service-monitor/reports/${reportIdA}/compare/${reportIdB}/export?format=${format}`
}
/** 查询巡检运行状态 */
export async function getRunStatus(targetId: string): Promise<RunStatusResponse> {
const res = await http.get<RunStatusResponse>(
`/api/service-monitor/run/status/${targetId}`
)
return res.data
}
/** 取消巡检 */
export async function cancelRun(runId: string): Promise<{ success: boolean }> {
const res = await http.post<{ success: boolean }>(
`/api/service-monitor/run/${runId}/cancel`
)
return res.data
}
/** 流式巡检(SSE) */
export function runInspection(
targetId: string,
suite: 'quick' | 'full' = 'quick',
handlers: Record<string, (data: InspectProgressEvent) => void>,
onError?: (error: Event) => void
) {
return createSSE({
url: '/api/service-monitor/run/stream',
params: { target_id: targetId, suite },
handlers,
onError,
})
}
\ No newline at end of file
/**
* 服务监测 - 定时任务 API
*/
import http from '@/utils/http'
import type {
Schedule,
CreateScheduleRequest,
UpdateScheduleRequest,
} from '@/types/service-monitor'
/** 获取定时任务列表 */
export async function getSchedules(): Promise<{ success: boolean; schedules: Schedule[] }> {
const res = await http.get<{ success: boolean; schedules: Schedule[] }>(
'/api/service-monitor/schedules'
)
return res.data
}
/** 创建定时任务 */
export async function createSchedule(
data: CreateScheduleRequest
): Promise<{ success: boolean; schedule: Schedule }> {
const res = await http.post<{ success: boolean; schedule: Schedule }>(
'/api/service-monitor/schedules',
data
)
return res.data
}
/** 更新定时任务 */
export async function updateSchedule(
scheduleId: string,
data: UpdateScheduleRequest
): Promise<{ success: boolean; schedule: Schedule }> {
const res = await http.put<{ success: boolean; schedule: Schedule }>(
`/api/service-monitor/schedules/${scheduleId}`,
data
)
return res.data
}
/** 删除定时任务 */
export async function deleteSchedule(scheduleId: string): Promise<{ success: boolean }> {
const res = await http.delete<{ success: boolean }>(
`/api/service-monitor/schedules/${scheduleId}`
)
return res.data
}
/** 切换定时任务启用/禁用 */
export async function toggleSchedule(
scheduleId: string
): Promise<{ success: boolean; schedule: Schedule }> {
const res = await http.post<{ success: boolean; schedule: Schedule }>(
`/api/service-monitor/schedules/${scheduleId}/toggle`
)
return res.data
}
/** 手动执行定时任务 */
export async function runSchedule(
scheduleId: string
): Promise<{ success: boolean; schedule_id: string; triggered: boolean }> {
const res = await http.post<{ success: boolean; schedule_id: string; triggered: boolean }>(
`/api/service-monitor/schedules/${scheduleId}/run`
)
return res.data
}
\ No newline at end of file
/**
* 服务监测 - 统计 API
*/
import http from '@/utils/http'
import type {
OverviewStats,
ModuleStats,
ItemTrendData,
AbnormalItem,
ItemKeysModule,
} from '@/types/service-monitor'
interface StatsParams {
target_id?: string
start_date?: string
end_date?: string
}
/** 获取概览统计 */
export async function getOverviewStats(
params?: StatsParams
): Promise<{ success: boolean; data: OverviewStats }> {
const res = await http.get<{ success: boolean; data: OverviewStats }>(
'/api/service-monitor/statistics/overview',
{ params }
)
return res.data
}
/** 获取模块健康统计 */
export async function getModuleStats(
params?: StatsParams
): Promise<{ success: boolean; data: { modules: ModuleStats[] } }> {
const res = await http.get<{ success: boolean; data: { modules: ModuleStats[] } }>(
'/api/service-monitor/statistics/modules',
{ params }
)
return res.data
}
/** 获取检测项趋势 */
export async function getItemTrend(
itemKey: string,
params?: StatsParams
): Promise<{ success: boolean; data: ItemTrendData }> {
const res = await http.get<{ success: boolean; data: ItemTrendData }>(
'/api/service-monitor/statistics/item-trend',
{ params: { ...params, item_key: itemKey } }
)
return res.data
}
/** 获取高频异常项 */
export async function getAbnormalItems(
params?: StatsParams
): Promise<{ success: boolean; data: { items: AbnormalItem[] } }> {
const res = await http.get<{ success: boolean; data: { items: AbnormalItem[] } }>(
'/api/service-monitor/statistics/abnormal-items',
{ params }
)
return res.data
}
/** 获取检测项键列表 */
export async function getItemKeys(
params?: StatsParams
): Promise<{ success: boolean; data: ItemKeysModule[] }> {
const res = await http.get<{ success: boolean; data: ItemKeysModule[] }>(
'/api/service-monitor/statistics/item-keys',
{ params }
)
return res.data
}
\ No newline at end of file
/**
* 服务监测 - 目标管理 API
*/
import http from '@/utils/http'
import type {
Target,
CreateTargetRequest,
UpdateTargetRequest,
TestConnectionRequest,
TestConnectionResponse,
} from '@/types/service-monitor'
/** 获取目标列表 */
export async function getTargets(): Promise<{ success: boolean; targets: Target[] }> {
const res = await http.get<{ success: boolean; targets: Target[] }>('/api/service-monitor/targets')
return res.data
}
/** 创建目标 */
export async function createTarget(data: CreateTargetRequest): Promise<{ success: boolean; target: Target }> {
const res = await http.post<{ success: boolean; target: Target }>('/api/service-monitor/targets', data)
return res.data
}
/** 更新目标 */
export async function updateTarget(
targetId: string,
data: UpdateTargetRequest
): Promise<{ success: boolean; target: Target }> {
const res = await http.put<{ success: boolean; target: Target }>(
`/api/service-monitor/targets/${targetId}`,
data
)
return res.data
}
/** 删除目标 */
export async function deleteTarget(targetId: string): Promise<{ success: boolean }> {
const res = await http.delete<{ success: boolean }>(`/api/service-monitor/targets/${targetId}`)
return res.data
}
/** 测试连接 */
export async function testConnection(data: TestConnectionRequest): Promise<TestConnectionResponse> {
const res = await http.post<TestConnectionResponse>('/api/service-monitor/targets/test', data)
return res.data
}
\ No newline at end of file
/**
* 问题排查 API
*/
import http from '@/utils/http'
import { createSSE } from '@/utils/sse'
import type {
TroubleshootRequest,
TroubleshootResponse,
SearchRequest,
SearchResponse,
AnalyzeRequest,
AnalyzeResponse,
HealthResponse,
CacheStatsResponse,
CacheClearResponse,
ExportRequest,
SubmitRequest,
SubmitResponse,
AnalyzeStreamEvent,
} from '@/types/troubleshoot'
import type { ProjectsResponse, CategoriesResponse } from '@/types/api'
/** 完整排查流程 */
export async function troubleshoot(data: TroubleshootRequest): Promise<TroubleshootResponse> {
const res = await http.post<TroubleshootResponse>('/api/troubleshoot', data)
return res.data
}
/** 搜索匹配案例 */
export async function search(data: SearchRequest): Promise<SearchResponse> {
const res = await http.post<SearchResponse>('/api/search', data)
return res.data
}
/** AI 深度分析 */
export async function analyze(data: AnalyzeRequest): Promise<AnalyzeResponse> {
const res = await http.post<AnalyzeResponse>('/api/analyze', data)
return res.data
}
/** 流式 AI 分析(SSE) */
export function analyzeStream(
params: {
project_name?: string
system_type?: string
apk_product?: string
query: string
model?: string
},
handlers: Record<string, (data: AnalyzeStreamEvent) => void>,
onError?: (error: Event) => void
) {
return createSSE({
url: '/api/analyze/stream',
params: params as Record<string, string>,
handlers,
onError,
})
}
/** 健康检查 */
export async function health(): Promise<HealthResponse> {
const res = await http.get<HealthResponse>('/api/health')
return res.data
}
/** 获取项目列表 */
export async function getProjects(): Promise<ProjectsResponse> {
const res = await http.get<ProjectsResponse>('/api/projects')
return res.data
}
/** 获取分类列表 */
export async function getCategories(): Promise<CategoriesResponse> {
const res = await http.get<CategoriesResponse>('/api/categories')
return res.data
}
/** 获取缓存统计 */
export async function getCacheStats(): Promise<CacheStatsResponse> {
const res = await http.get<CacheStatsResponse>('/api/cache/stats')
return res.data
}
/** 清除缓存 */
export async function clearCache(): Promise<CacheClearResponse> {
const res = await http.post<CacheClearResponse>('/api/cache/clear')
return res.data
}
/** 导出 Word 报告 */
export async function exportReport(data: ExportRequest): Promise<Blob> {
const res = await http.post('/api/export', data, {
responseType: 'blob',
})
return res.data
}
/** 提交问题记录 */
export async function submitRecord(data: SubmitRequest): Promise<SubmitResponse> {
const res = await http.post<SubmitResponse>('/api/submit', data)
return res.data
}
\ No newline at end of file
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AppEmpty: typeof import('./components/common/AppEmpty.vue')['default']
AppLoading: typeof import('./components/common/AppLoading.vue')['default']
AppNavbar: typeof import('./components/layout/AppNavbar.vue')['default']
AppSidebar: typeof import('./components/layout/AppSidebar.vue')['default']
AppTopbar: typeof import('./components/layout/AppTopbar.vue')['default']
AppVersion: typeof import('./components/common/AppVersion.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElUpload: typeof import('element-plus/es')['ElUpload']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
<script setup lang="ts">
withDefaults(defineProps<{
description?: string
icon?: string
}>(), {
description: '暂无数据',
icon: '📭',
})
</script>
<template>
<div class="app-empty">
<div class="empty-icon">{{ icon }}</div>
<div class="empty-text">{{ description }}</div>
</div>
</template>
<style scoped lang="scss">
.app-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: $gray-500;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-text {
font-size: 14px;
}
</style>
\ No newline at end of file
<script setup lang="ts">
withDefaults(defineProps<{
text?: string
fullscreen?: boolean
}>(), {
text: '加载中...',
fullscreen: false,
})
</script>
<template>
<div v-if="fullscreen" class="app-loading-fullscreen">
<div class="loading-spinner"></div>
<div class="loading-text">{{ text }}</div>
</div>
<div v-else class="app-loading">
<div class="loading-spinner"></div>
<span class="loading-text">{{ text }}</span>
</div>
</template>
<style scoped lang="scss">
.app-loading {
display: inline-flex;
align-items: center;
gap: 8px;
color: $gray-500;
font-size: 14px;
}
.app-loading-fullscreen {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
z-index: 9999;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid $gray-200;
border-top-color: $primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
font-size: 14px;
color: $gray-500;
margin-top: 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
\ No newline at end of file
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import http from '@/utils/http'
const version = ref('')
const buildTime = ref('')
onMounted(async () => {
try {
const res = await http.get('/api/version')
if (res.data?.success) {
version.value = res.data.data?.version || ''
buildTime.value = res.data.data?.buildTime || ''
}
} catch {
// 静默失败
}
})
</script>
<template>
<span v-if="version" class="app-version" :title="buildTime ? `构建时间: ${buildTime}` : ''">
v{{ version }}
</span>
</template>
<style scoped lang="scss">
.app-version {
background: rgba(255, 255, 255, 0.15);
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
cursor: default;
}
</style>
\ No newline at end of file
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import AppVersion from '@/components/common/AppVersion.vue'
withDefaults(defineProps<{
showModule?: boolean
moduleLabel?: string
logsPath?: string
}>(), {
showModule: false,
moduleLabel: '',
logsPath: '',
})
const router = useRouter()
const userStore = useUserStore()
function handleLogout() {
userStore.logout().then(() => {
router.push('/login')
})
}
function handleHome() {
router.push('/')
}
</script>
<template>
<header class="navbar">
<div class="navbar-left">
<a
href="javascript:void(0)"
class="navbar-home"
@click="handleHome"
>
运行维护平台
</a>
<span v-if="showModule" class="navbar-separator">/</span>
<span v-if="showModule" class="navbar-module">{{ moduleLabel }}</span>
<a v-if="logsPath" :href="logsPath" class="navbar-logs" @click.prevent="router.push(logsPath)">📋 操作日志</a>
</div>
<div class="navbar-right">
<AppVersion />
<span class="navbar-user">{{ userStore.displayName }}</span>
<button class="btn-logout" @click="handleLogout">退出</button>
</div>
</header>
</template>
<style scoped lang="scss">
.navbar {
position: fixed;
left: 0;
top: 0;
right: 0;
height: 56px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 99;
@media screen and (max-width: 768px) {
padding: 0 12px;
}
}
.navbar-left {
display: flex;
align-items: center;
gap: 10px;
}
.navbar-home {
color: #fff;
text-decoration: none;
font-size: 16px;
font-weight: 700;
cursor: pointer;
&:hover {
opacity: 0.85;
}
}
.navbar-separator {
color: rgba(255, 255, 255, 0.5);
font-size: 16px;
}
.navbar-module {
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
}
.navbar-logs {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
text-decoration: none;
background: rgba(255, 255, 255, 0.12);
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
}
.navbar-right {
display: flex;
align-items: center;
gap: 14px;
}
.navbar-user {
color: #fff;
font-size: 14px;
@media screen and (max-width: 768px) {
font-size: 13px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.btn-logout {
background: rgba(255, 255, 255, 0.15);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
&:hover {
background: rgba(255, 255, 255, 0.25);
}
@media screen and (max-width: 768px) {
padding: 6px 10px;
font-size: 12px;
}
}
</style>
\ No newline at end of file
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export interface SidebarMenuItem {
key: string
icon: string
label: string
path: string
}
const props = withDefaults(defineProps<{
menus: SidebarMenuItem[]
brandIcon?: string
brandTitle?: string
mobileOpen?: boolean
}>(), {
brandIcon: '📊',
brandTitle: '运维',
mobileOpen: false,
})
const emit = defineEmits<{
(e: 'update:mobileOpen', value: boolean): void
}>()
const route = useRoute()
const router = useRouter()
const activeKey = computed(() => route.meta.activeMenu as string || '')
function handleMenuClick(item: SidebarMenuItem) {
if (props.mobileOpen) {
emit('update:mobileOpen', false)
}
router.push(item.path)
}
function closeMobile() {
emit('update:mobileOpen', false)
}
</script>
<template>
<Teleport to="body">
<div
v-if="mobileOpen"
class="sidebar-overlay"
@click="closeMobile"
></div>
</Teleport>
<aside class="sidebar" :class="{ open: mobileOpen }">
<div class="sidebar-brand">
<span class="brand-icon">{{ brandIcon }}</span>
<span class="brand-title">{{ brandTitle }}</span>
</div>
<nav class="sidebar-nav">
<a
v-for="item in menus"
:key="item.key"
:class="['nav-item', { active: activeKey === item.key }]"
href="javascript:void(0)"
@click="handleMenuClick(item)"
>
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</a>
</nav>
</aside>
</template>
<style scoped lang="scss">
.sidebar-overlay {
display: none;
@media screen and (max-width: 768px) {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
}
}
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 200px;
height: 100vh;
background: linear-gradient(180deg, #1e293b 0%, #334155 100%);
z-index: 100;
transition: transform 0.3s ease;
@media screen and (max-width: 768px) {
transform: translateX(-200px);
&.open {
transform: translateX(0);
}
}
@media screen and (max-width: 480px) {
width: 180px;
}
}
.sidebar-brand {
color: #fff;
font-size: 18px;
font-weight: 700;
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 8px;
@media screen and (max-width: 480px) {
font-size: 14px;
padding: 14px;
}
}
.brand-icon {
font-size: 20px;
@media screen and (max-width: 480px) {
font-size: 16px;
}
}
.sidebar-nav {
padding: 16px 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
color: #94a3b8;
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
&.active {
background: $primary;
color: #fff;
}
@media screen and (max-width: 480px) {
padding: 10px 14px;
font-size: 12px;
gap: 10px;
}
}
.nav-icon {
font-size: 16px;
width: 20px;
text-align: center;
@media screen and (max-width: 480px) {
font-size: 14px;
width: 18px;
}
}
</style>
\ No newline at end of file
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
import AppVersion from '@/components/common/AppVersion.vue'
withDefaults(defineProps<{
showHome?: boolean
showHamburger?: boolean
}>(), {
showHome: false,
showHamburger: false,
})
const router = useRouter()
const userStore = useUserStore()
const appStore = useAppStore()
function handleLogout() {
userStore.logout().then(() => {
router.push('/login')
})
}
function handleHome() {
router.push('/')
}
</script>
<template>
<header class="topbar">
<div class="topbar-left">
<button
v-if="showHamburger"
class="hamburger"
@click="appStore.toggleMobileSidebar()"
>
</button>
<a
v-if="showHome"
href="javascript:void(0)"
class="topbar-home"
@click="handleHome"
>
🏠 首页
</a>
</div>
<div class="topbar-right">
<AppVersion />
<div class="topbar-user">
<span>{{ userStore.displayName }}</span>
<button class="btn-logout" @click="handleLogout">退出</button>
</div>
</div>
</header>
</template>
<style scoped lang="scss">
.topbar {
position: fixed;
left: 200px;
top: 0;
right: 0;
height: 56px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 99;
@media screen and (max-width: 768px) {
left: 0;
padding: 0 12px;
}
@media screen and (max-width: 480px) {
padding: 0 10px;
height: 52px;
}
}
.topbar-left {
display: flex;
align-items: center;
gap: 16px;
}
.hamburger {
display: none;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.35);
color: #fff;
font-size: 18px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
@media screen and (max-width: 768px) {
display: block;
}
@media screen and (max-width: 480px) {
padding: 6px 10px;
font-size: 16px;
}
}
.topbar-home {
background: rgba(255, 255, 255, 0.2);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.35);
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
@media screen and (max-width: 768px) {
display: none;
}
}
.topbar-right {
display: flex;
align-items: center;
}
.topbar-user {
color: #fff;
display: flex;
align-items: center;
gap: 14px;
font-size: 14px;
@media screen and (max-width: 768px) {
font-size: 13px;
gap: 8px;
}
}
.btn-logout {
background: rgba(255, 255, 255, 0.15);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
&:hover {
background: rgba(255, 255, 255, 0.25);
}
@media screen and (max-width: 768px) {
padding: 6px 10px;
font-size: 12px;
}
@media screen and (max-width: 480px) {
padding: 5px 8px;
font-size: 11px;
}
}
</style>
\ No newline at end of file
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/stores/app'
import AppSidebar from '@/components/layout/AppSidebar.vue'
import AppTopbar from '@/components/layout/AppTopbar.vue'
const appStore = useAppStore()
// 服务管理侧边栏菜单
const manageMenus = computed(() => [
{ key: 'authorization', icon: '🔑', label: '服务授权', path: '/service-manage/authorization' },
{ key: 'upgrade', icon: '🚀', label: '服务升级', path: '/service-manage/upgrade' },
{ key: 'info', icon: 'ℹ️', label: '服务信息', path: '/service-manage/info' },
{ key: 'logs', icon: '📝', label: '操作日志', path: '/service-manage/logs' },
])
</script>
<template>
<div class="layout-manage">
<AppSidebar
:menus="manageMenus"
brand-icon="🛠️"
brand-title="服务管理"
v-model:mobile-open="appStore.sidebarMobileOpen"
/>
<AppTopbar :show-home="true" :show-hamburger="true" />
<main class="main-content">
<router-view />
</main>
</div>
</template>
<style scoped lang="scss">
.layout-manage {
min-height: 100vh;
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import AppSidebar from '@/components/layout/AppSidebar.vue'
import AppTopbar from '@/components/layout/AppTopbar.vue'
const appStore = useAppStore()
const userStore = useUserStore()
// 侧边栏菜单
const monitorMenus = computed(() => {
const base = [
{ key: 'targets', icon: '📊', label: '监测目标', path: '/service-monitor' },
{ key: 'reports', icon: '📋', label: '巡检报告', path: '/service-monitor/reports' },
{ key: 'statistics', icon: '📈', label: '监测统计', path: '/service-monitor/statistics' },
{ key: 'schedule', icon: '⏰', label: '定时任务', path: '/service-monitor/schedule' },
{ key: 'logs', icon: '📝', label: '操作日志', path: '/service-monitor/logs' },
]
if (userStore.isAdmin) {
base.push(
{ key: 'notification', icon: '🔔', label: '通知配置', path: '/service-monitor/notification' },
{ key: 'manage', icon: '⚙️', label: '目标管理', path: '/service-monitor/targets' }
)
}
return base
})
</script>
<template>
<div class="layout-monitor">
<AppSidebar
:menus="monitorMenus"
brand-icon="🛠️"
brand-title="运维"
v-model:mobile-open="appStore.sidebarMobileOpen"
/>
<AppTopbar :show-home="true" :show-hamburger="true" />
<main class="main-content">
<router-view />
</main>
</div>
</template>
<style scoped lang="scss">
.layout-monitor {
min-height: 100vh;
}
</style>
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
// 样式
import 'element-plus/dist/index.css'
import '@/styles/index.scss'
// ECharts 按需注册
import '@/plugins/echarts'
const app = createApp(App)
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart, PieChart, GaugeChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
ToolboxComponent
} from 'echarts/components'
// 按需注册 ECharts 组件
use([
CanvasRenderer,
LineChart,
BarChart,
PieChart,
GaugeChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
ToolboxComponent
])
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/user'
// 路由表 -- 映射现有 Flask 页面路由
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false, title: '登录' }
},
{
path: '/',
name: 'Platform',
component: () => import('@/views/Platform.vue'),
meta: { requiresAuth: true, title: '运行维护平台' }
},
// 问题排查助手 -- 独立布局(无侧边栏)
{
path: '/troubleshoot',
name: 'Troubleshoot',
component: () => import('@/views/troubleshoot/Index.vue'),
meta: { requiresAuth: true, title: '问题排查助手' }
},
{
path: '/troubleshoot/logs',
name: 'TroubleshootLogs',
component: () => import('@/views/troubleshoot/Logs.vue'),
meta: { requiresAuth: true, title: '操作日志' }
},
// 服务监测 -- 侧边栏布局
{
path: '/service-monitor',
component: () => import('@/layouts/MonitorLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'MonitorIndex',
component: () => import('@/views/service-monitor/Index.vue'),
meta: { activeMenu: 'targets', title: '监测目标' }
},
{
path: 'targets',
name: 'MonitorTargets',
component: () => import('@/views/service-monitor/Targets.vue'),
meta: { activeMenu: 'manage', title: '目标管理', requiresAdmin: true }
},
{
path: 'reports',
name: 'MonitorReports',
component: () => import('@/views/service-monitor/Reports.vue'),
meta: { activeMenu: 'reports', title: '巡检报告' }
},
{
path: 'report/:reportId',
name: 'MonitorReportDetail',
component: () => import('@/views/service-monitor/ReportDetail.vue'),
meta: { activeMenu: 'reports', title: '报告详情' }
},
{
path: 'statistics',
name: 'MonitorStatistics',
component: () => import('@/views/service-monitor/Statistics.vue'),
meta: { activeMenu: 'statistics', title: '监测统计' }
},
{
path: 'schedule',
name: 'MonitorSchedule',
component: () => import('@/views/service-monitor/Schedule.vue'),
meta: { activeMenu: 'schedule', title: '定时任务' }
},
{
path: 'notification',
name: 'MonitorNotification',
component: () => import('@/views/service-monitor/Notification.vue'),
meta: { activeMenu: 'notification', title: '通知配置', requiresAdmin: true }
},
{
path: 'run/:targetId',
name: 'MonitorRun',
component: () => import('@/views/service-monitor/Run.vue'),
meta: { activeMenu: 'targets', title: '巡检执行', requiresAdmin: true }
},
{
path: 'compare/:reportIdA/:reportIdB',
name: 'MonitorCompare',
component: () => import('@/views/service-monitor/Compare.vue'),
meta: { activeMenu: 'reports', title: '报告对比' }
},
{
path: 'logs',
name: 'MonitorLogs',
component: () => import('@/views/service-monitor/Logs.vue'),
meta: { activeMenu: 'logs', title: '操作日志' }
},
]
},
// 服务管理 -- 侧边栏布局
{
path: '/service-manage',
component: () => import('@/layouts/ManageLayout.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: '',
redirect: '/service-manage/authorization'
},
{
path: 'authorization',
name: 'ManageAuthorization',
component: () => import('@/views/service-manage/Authorization.vue'),
meta: { activeMenu: 'authorization', title: '服务授权' }
},
{
path: 'upgrade',
name: 'ManageUpgrade',
component: () => import('@/views/service-manage/Upgrade.vue'),
meta: { activeMenu: 'upgrade', title: '服务升级' }
},
{
path: 'info',
name: 'ManageInfo',
component: () => import('@/views/service-manage/Info.vue'),
meta: { activeMenu: 'info', title: '服务信息' }
},
{
path: 'logs',
name: 'ManageLogs',
component: () => import('@/views/service-manage/Logs.vue'),
meta: { activeMenu: 'logs', title: '操作日志' }
},
]
},
// 404 兜底
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue'),
meta: { requiresAuth: false, title: '页面不存在' }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach(async (to, _from, next) => {
const userStore = useUserStore()
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 运行维护平台` : '运行维护平台'
// 不需要认证的页面直接放行
if (to.meta.requiresAuth === false) {
// 已登录用户访问登录页,跳转首页
if (to.name === 'Login' && userStore.isLoggedIn) {
next({ path: '/' })
return
}
next()
return
}
// 需要认证但未登录
if (!userStore.isLoggedIn) {
// 尝试从后端恢复会话
if (!userStore.hasCheckedAuth) {
try {
await userStore.fetchUserInfo()
// 恢复成功,继续检查权限
} catch {
// 会话失效,跳转登录
next({ name: 'Login', query: { next: to.fullPath } })
return
}
} else {
// 已检查过但未登录
next({ name: 'Login', query: { next: to.fullPath } })
return
}
}
// 管理员权限检查
if (to.meta.requiresAdmin && !userStore.isAdmin) {
next({ path: '/' })
return
}
next()
})
export default router
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref(false)
const sidebarMobileOpen = ref(false)
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
function toggleMobileSidebar() {
sidebarMobileOpen.value = !sidebarMobileOpen.value
}
function closeMobileSidebar() {
sidebarMobileOpen.value = false
}
return {
sidebarCollapsed,
sidebarMobileOpen,
toggleSidebar,
toggleMobileSidebar,
closeMobileSidebar
}
})
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as authApi from '@/api/auth'
import type { UserInfo } from '@/types/api'
export const useUserStore = defineStore('user', () => {
// State
const user = ref<UserInfo | null>(null)
const hasCheckedAuth = ref(false)
// Getters
const isLoggedIn = computed(() => !!user.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const displayName = computed(() => {
if (!user.value) return ''
const roleText = user.value.role === 'admin' ? '管理员' : '用户'
return `${user.value.username}${roleText})`
})
// Actions
async function login(username: string, password: string, remember: boolean = false) {
const res = await authApi.login({ username, password, remember })
if (res.success) {
user.value = res.user
hasCheckedAuth.value = true
return res.next || '/'
}
throw new Error('登录失败')
}
async function logout() {
try {
await authApi.logout()
} finally {
user.value = null
hasCheckedAuth.value = false
}
}
async function fetchUserInfo() {
const res = await authApi.getUserInfo()
if (res.success) {
user.value = res.user
hasCheckedAuth.value = true
return res.user
}
throw new Error('未登录')
}
function clearUser() {
user.value = null
hasCheckedAuth.value = false
}
return {
user,
hasCheckedAuth,
isLoggedIn,
isAdmin,
displayName,
login,
logout,
fetchUserInfo,
clearUser
}
})
\ No newline at end of file
// 全局重置
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Microsoft YaHei', '微软雅黑', sans-serif;
background: #f3f4f6;
min-height: 100vh;
color: $gray-900;
font-size: 14px;
line-height: 1.5;
}
#app {
min-height: 100vh;
}
a {
color: $primary;
text-decoration: none;
transition: color 0.2s;
&:hover {
color: $primary-hover;
}
}
// 布局通用类(服务监测 / 服务管理)
.layout-monitor,
.layout-manage {
min-height: 100vh;
}
.main-content {
margin-left: 200px;
padding: 84px 24px 28px;
min-height: 100vh;
transition: margin-left 0.3s;
@media screen and (max-width: 768px) {
margin-left: 0;
padding: 72px 12px 24px;
}
}
// 通用容器
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 16px;
}
// 响应式断点(与现有模板一致)
$breakpoint-tablet: 768px;
$breakpoint-mobile: 480px;
// 滚动条样式优化
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: $gray-100;
}
::-webkit-scrollbar-thumb {
background: $gray-300;
border-radius: 4px;
&:hover {
background: $gray-400;
}
}
/**
* 通用 API 响应类型
*/
/** 统一成功响应(基础) */
export interface ApiSuccessResponse {
success: true
}
/** 统一错误响应 */
export interface ApiErrorResponse {
success: false
error: {
code: number
message: string
}
}
/** 统一响应(成功或错误) */
export type ApiResponse = ApiSuccessResponse | ApiErrorResponse
/** 分页请求参数 */
export interface PaginationParams {
page?: number
page_size?: number
}
/** 日期范围参数 */
export interface DateRangeParams {
start_date?: string // YYYY-MM-DD
end_date?: string // YYYY-MM-DD
}
/** 用户信息 */
export interface UserInfo {
id: number
username: string
role: 'admin' | 'user'
}
/** 登录请求 */
export interface LoginRequest {
username: string
password: string
remember?: boolean
next?: string
}
/** 登录响应 */
export interface LoginResponse {
success: boolean
user: UserInfo
next: string
}
/** 项目列表响应 */
export interface ProjectsResponse {
success: boolean
projects: string[]
}
/** 分类列表响应 */
export interface CategoriesResponse {
success: boolean
categories: string[]
}
\ No newline at end of file
/**
* 服务监测 API 类型
*/
// ============================================================
// 监测目标
// ============================================================
export interface Target {
id: string
name: string
type: 'local' | 'remote'
host: string
port: number
username: string
has_password: boolean
built_in: boolean
container_overrides: Record<string, string>
thresholds: Record<string, number | string>
created_at?: string
updated_at?: string
}
export interface CreateTargetRequest {
name: string
host: string
port?: number
username: string
password: string
container_overrides?: Record<string, string>
credential_overrides?: Record<string, string>
thresholds?: Record<string, number | string>
}
export interface UpdateTargetRequest {
name?: string
host?: string
port?: number
username?: string
password?: string
container_overrides?: Record<string, string>
credential_overrides?: Record<string, string>
thresholds?: Record<string, number | string>
}
export interface TestConnectionRequest {
host: string
port?: number
username: string
password: string
}
export interface TestConnectionResponse {
success: boolean
message: string
detail: string | null
error_code: string | null
}
// ============================================================
// 巡检执行
// ============================================================
export interface RunStatusResponse {
running: boolean
run_id?: string
target_id?: string
suite?: string
done?: number
total?: number
current?: string
}
export interface ModuleSummary {
正常: number
警告: number
严重: number
total: number
}
export interface InspectProgressEvent {
event: 'progress' | 'module_done' | 'finished' | 'error' | 'cancelled'
run_id: string
done?: number
total?: number
current?: string
module?: string
items_count?: number
summary?: ModuleSummary
report_id?: string
message?: string
}
// ============================================================
// 巡检报告
// ============================================================
export interface ReportSummary {
id: string
target_id: string
target_name: string
target_type: string
suite: 'quick' | 'full'
started_at: string
finished_at: string
summary: ModuleSummary
}
export interface CheckItem {
key: string
name: string
value: string
threshold: string
status: '正常' | '警告' | '严重'
}
export interface ModuleResult {
id: string
name: string
category: string
items: CheckItem[]
summary: ModuleSummary
}
export interface ReportDetail {
id: string
target_id: string
target_name: string
target_type: string
suite: 'quick' | 'full'
started_at: string
finished_at: string
summary: ModuleSummary
modules: ModuleResult[]
access_token: string
access_token_expires_at: string
}
export interface BatchDeleteReportsRequest {
ids: string[]
}
// ============================================================
// 报告对比
// ============================================================
export interface CompareReportSummary {
id: string
target_id: string
target_name: string
target_type: string
suite: string
created_at: string
total_items: number
normal_count: number
warning_count: number
critical_count: number
}
export interface DiffItem {
key: string
name: string
module_id: string
module_name: string
diff_type: 'new_abnormal' | 'recovered' | 'value_changed' | 'new_item' | 'removed_item'
value_a: string
value_b: string
status_a: string
status_b: string
change_desc: string
}
export interface DiffGroup {
module_id: string
module_name: string
items: DiffItem[]
}
export interface CompareResult {
report_a: CompareReportSummary
report_b: CompareReportSummary
diff_summary: {
new_abnormal: number
recovered: number
value_changed: number
new_items: number
removed_items: number
}
diff_details: {
new_abnormal: DiffGroup[]
recovered: DiffGroup[]
value_changed: DiffGroup[]
new_items: DiffGroup[]
removed_items: DiffGroup[]
}
}
// ============================================================
// 定时任务
// ============================================================
export interface Schedule {
id: string
name: string
target_id: string
target_name: string
suite: 'quick' | 'full'
cron: string
repeat_mode: 'daily' | 'weekday' | 'weekly'
hour: number
minute: number
weekdays: number[]
start_date: string | null
end_date: string | null
enabled: boolean
current_status: 'idle' | 'running' | 'success' | 'failed' | 'error'
last_run_at: string | null
last_run_status: string | null
last_report_id: string | null
next_run_at: string | null
created_at: string
created_by: string
}
export interface CreateScheduleRequest {
name: string
target_id: string
suite?: 'quick' | 'full'
repeat_mode?: 'daily' | 'weekday' | 'weekly'
hour?: number
minute?: number
weekdays?: number[]
start_date?: string
end_date?: string
}
export type UpdateScheduleRequest = Partial<CreateScheduleRequest>
// ============================================================
// 通知配置
// ============================================================
export interface EmailConfig {
enabled: boolean
smtp_host: string
smtp_port: number
smtp_user: string
smtp_password: string
use_ssl: boolean
recipients: string[]
subject_template: string
}
export interface DingTalkConfig {
enabled: boolean
webhook_url: string
secret: string
at_mobiles: string[]
}
export interface WeComConfig {
enabled: boolean
webhook: string
}
export interface NotificationTrigger {
on_complete: boolean
on_abnormal_only: boolean
include_details: boolean
include_link: boolean
}
export interface AlertOnConsecutiveConfig {
enabled: boolean
consecutive_threshold: number
alert_level: 'critical' | 'all'
channels: string[]
}
export interface NotificationConfig {
email: EmailConfig
dingtalk: DingTalkConfig
wecom: WeComConfig
trigger: NotificationTrigger
alert_on_consecutive?: AlertOnConsecutiveConfig
}
export interface NotificationConfigResponse {
success: boolean
config: NotificationConfig & {
updated_at: string | null
updated_by: string | null
}
}
// ============================================================
// 监测统计
// ============================================================
export interface OverviewStats {
total_reports: number
total_items: number
normal_count: number
warning_count: number
critical_count: number
abnormal_rate: number
most_abnormal_item: {
key: string
name: string
abnormal_count: number
} | null
date_range: {
start: string
end: string
}
}
export interface ModuleStats {
id: string
name: string
category: string
total: number
normal: number
warning: number
critical: number
pass_rate: number
}
export interface ItemTrendDataPoint {
time: string
value_num: number | null
value_raw: string
status: '正常' | '警告' | '严重'
}
export interface ItemTrendData {
item_key: string
item_name: string
unit: string
threshold: string
data_points: ItemTrendDataPoint[]
status_distribution: {
正常: number
警告: number
严重: number
}
}
export interface AbnormalItem {
key: string
name: string
module_name: string
category: string
total_appearances: number
abnormal_count: number
abnormal_rate: number
warning_count: number
critical_count: number
}
export interface ItemKeyInfo {
key: string
name: string
has_numeric: boolean
}
export interface ItemKeysModule {
module_id: string
module_name: string
category: string
items: ItemKeyInfo[]
}
\ No newline at end of file
/**
* 问题排查 API 类型
*/
/** 匹配案例 */
export interface MatchedCase {
rank: number
score: number
project: string
title: string
file: string
phenomenon: string
}
/** 排查请求 */
export interface TroubleshootRequest {
project_name?: string
system_type?: string
apk_product?: string
query: string
}
/** 排查响应 */
export interface TroubleshootResponse {
success: boolean
response: string
matched_cases: MatchedCase[]
filter_warnings?: string[]
api_time: number
offline?: boolean
}
/** 搜索请求 */
export interface SearchRequest {
project_name?: string
query: string
}
/** 搜索响应 */
export interface SearchResponse {
success: boolean
matched_cases: MatchedCase[]
matched_count: number
search_time: number
}
/** AI 分析请求 */
export interface AnalyzeRequest {
project_name?: string
system_type?: string
apk_product?: string
query: string
matched_cases?: MatchedCase[]
}
/** AI 分析响应 */
export interface AnalyzeResponse {
success: boolean
response: string
filter_warnings?: string[]
api_time: number
offline?: boolean
}
/** 健康检查响应 */
export interface HealthResponse {
status: 'ok' | 'error'
timestamp: string
version: string
offline_mode: boolean
knowledge_base: {
total_records: number
last_update: string
}
search: {
mode: string
vector_loaded: boolean
embedding_model: string
}
cache: {
total_files: number
total_size_mb: number
}
scheduler: {
initialized: boolean
running?: boolean
job_count?: number
reason?: string
error?: string
}
components: {
search_engine: string
safety_filter: string
cache_manager: string
}
}
/** 缓存统计响应 */
export interface CacheStatsResponse {
success: boolean
stats: {
total_files: number
total_size_mb: number
oldest: string | null
newest: string | null
}
}
/** 清除缓存响应 */
export interface CacheClearResponse {
success: boolean
message: string
cleared_count: number
}
/** 导出请求 */
export interface ExportRequest {
project_name?: string
system_type?: string
apk_product?: string
query?: string
matched_cases?: MatchedCase[]
response?: string
}
/** 提交记录请求 */
export interface SubmitRequest {
project_name: string
system_type?: string
apk_product?: string
phenomenon: string
troubleshoot_steps?: string
root_cause?: string
solution?: string
recorder: string
}
/** 提交记录响应 */
export interface SubmitResponse {
success: boolean
message: string
record_id: string
file: string
}
/** SSE 流式分析事件 */
export interface AnalyzeStreamEvent {
type: 'start' | 'matched_cases' | 'chunk' | 'done' | 'error'
message?: string
cases?: MatchedCase[]
content?: string
api_time?: number
cached?: boolean
offline?: boolean
}
import axios from 'axios'
import type { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
import { useUserStore } from '@/stores/user'
import router from '@/router'
import { ElMessage } from 'element-plus'
// 创建 Axios 实例
const http: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
timeout: 60000,
withCredentials: true, // 关键:携带 Cookie(Flask Session)
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
http.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Flask Session + Cookie 模式,无需手动添加 Authorization header
// withCredentials: true 已确保 Cookie 自动携带
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器
http.interceptors.response.use(
(response: AxiosResponse) => {
return response
},
(error) => {
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
// 未登录 / Session 过期
const userStore = useUserStore()
userStore.clearUser()
// 避免在登录页重复跳转
if (router.currentRoute.value.name !== 'Login') {
router.push({
name: 'Login',
query: { next: router.currentRoute.value.fullPath }
})
}
ElMessage.warning('登录已过期,请重新登录')
break
case 403:
ElMessage.error('权限不足,仅管理员可访问')
break
case 409:
// 巡检冲突(已有巡检运行中)
ElMessage.warning(data?.error?.message || data?.message || '操作冲突')
break
default:
// 统一错误提示(兼容两种格式)
const errMsg = data?.error?.message || data?.error || data?.message || `请求失败 (${status})`
ElMessage.error(errMsg)
}
} else if (error.code === 'ECONNABORTED') {
ElMessage.error('请求超时,请稍后重试')
} else {
ElMessage.error('网络错误,请检查网络连接')
}
return Promise.reject(error)
}
)
export default http
/**
* Markdown 渲染工具
* 使用 marked 解析 + DOMPurify 消毒,支持 GFM(GitHub Flavored Markdown)
*/
import { marked } from 'marked'
import DOMPurify from 'dompurify'
// 配置 marked
marked.setOptions({
gfm: true, // 启用 GFM(表格、任务列表、删除线等)
breaks: true, // 换行符转为 <br>
})
/**
* 渲染 Markdown 为安全的 HTML
* @param text Markdown 文本
* @returns 安全的 HTML 字符串
*/
export function renderMarkdown(text: string): string {
if (!text) return ''
// 1. 使用 marked 解析 Markdown
const rawHtml = marked.parse(text) as string
// 2. 使用 DOMPurify 消毒,防止 XSS
const cleanHtml = DOMPurify.sanitize(rawHtml, {
// 允许的标签(覆盖默认,增加一些 Markdown 常用标签)
ALLOWED_TAGS: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr',
'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins',
'ul', 'ol', 'li',
'blockquote', 'pre', 'code',
'a', 'img',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'span', 'div',
],
// 允许的属性
ALLOWED_ATTR: [
'href', 'title', 'target', 'rel', // 链接
'src', 'alt', 'width', 'height', // 图片
'class', // 代码高亮类
],
// 链接强制添加 rel="noopener noreferrer"
ADD_ATTR: ['target'],
FORCE_BODY: true,
})
return cleanHtml
}
/**
* 渲染 Markdown 为纯文本(用于摘要/预览)
* @param text Markdown 文本
* @param maxLength 最大长度(默认 200)
* @returns 纯文本摘要
*/
export function renderMarkdownPlain(text: string, maxLength: number = 200): string {
if (!text) return ''
// 移除 Markdown 语法,保留纯文本
let plain = text
.replace(/#{1,6}\s+/g, '') // 移除标题标记
.replace(/\*\*(.+?)\*\*/g, '$1') // 移除加粗
.replace(/\*(.+?)\*/g, '$1') // 移除斜体
.replace(/`(.+?)`/g, '$1') // 移除行内代码
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // 移除链接,保留文字
.replace(/^[-*+]\s+/gm, '') // 移除无序列表标记
.replace(/^\d+\.\s+/gm, '') // 移除有序列表标记
.replace(/>\s+/g, '') // 移除引用标记
.replace(/\n+/g, ' ') // 换行转空格
.trim()
// 截断
if (plain.length > maxLength) {
plain = plain.slice(0, maxLength) + '...'
}
return plain
}
import { ElMessageBox, ElMessage } from 'element-plus'
/**
* 确认操作对话框
*/
export function confirmAction(message: string, title: string = '确认操作'): Promise<boolean> {
return ElMessageBox.confirm(message, title, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => true)
.catch(() => false)
}
/**
* 成功提示
*/
export function showSuccess(message: string) {
ElMessage.success(message)
}
/**
* 错误提示
*/
export function showError(message: string) {
ElMessage.error(message)
}
/**
* 警告提示
*/
export function showWarning(message: string) {
ElMessage.warning(message)
}
/**
* SSE 工具函数 — 为阶段二/三的流式功能做准备
*
* 阶段二将用于:
* 1. 问题排查 AI 分析流 /api/analyze/stream
* 事件类型:start / matched_cases / chunk / done / error
* 2. 服务监测巡检流 /api/service-monitor/run/stream
* 事件类型:progress / module_done / finished / error / cancelled
*
* 后端响应头:Cache-Control: no-cache, X-Accel-Buffering: no, Connection: keep-alive
*/
export interface SSEOptions {
/** SSE 端点 URL(相对路径,如 /api/analyze/stream) */
url: string
/** URL 查询参数 */
params?: Record<string, string>
/** 事件处理器映射(key 为事件 type) */
handlers: Record<string, (data: any) => void>
/** 连接错误回调 */
onError?: (error: Event) => void
/** 连接打开回调 */
onOpen?: () => void
/** 是否携带 Cookie(默认 true,Flask Session 需要) */
withCredentials?: boolean
}
export interface SSEConnection {
/** 关闭 SSE 连接 */
close: () => void
/** 获取原生 EventSource 实例(高级用法) */
getSource: () => EventSource | null
}
/**
* 创建通用 SSE 连接
*
* 用法示例(阶段二):
* const conn = createSSE({
* url: '/api/analyze/stream',
* params: { query: '...', project_name: '...' },
* handlers: {
* start: (e) => console.log('开始', e.message),
* chunk: (e) => appendContent(e.content),
* done: (e) => console.log('完成', e.api_time),
* error: (e) => console.error('错误', e.message),
* }
* })
* // 需要取消时
* conn.close()
*/
export function createSSE(options: SSEOptions): SSEConnection {
const {
url,
params = {},
handlers,
onError,
onOpen,
withCredentials = true,
} = options
// 构建带参数的 URL
const queryString = new URLSearchParams(params).toString()
const fullUrl = queryString ? `${url}?${queryString}` : url
// 创建 EventSource
const source = new EventSource(fullUrl, { withCredentials })
// 连接打开
if (onOpen) {
source.onopen = onOpen
}
// 监听消息
source.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
const handler = handlers[data.type]
if (handler) {
handler(data)
}
} catch (err) {
console.warn('SSE 消息解析失败:', event.data, err)
}
}
// 监听错误
source.onerror = (event) => {
if (onError) {
onError(event)
}
// 自动关闭连接(避免无限重连)
source.close()
}
return {
close: () => source.close(),
getSource: () => source,
}
}
/**
* 创建巡检 SSE 连接(服务监测专用)
*
* 事件类型:progress / module_done / finished / error / cancelled
* 阶段三实现具体逻辑时使用
*/
export function createInspectionSSE(
targetId: string,
suite: string,
handlers: Record<string, (data: any) => void>,
onError?: (error: Event) => void
): SSEConnection {
return createSSE({
url: '/api/service-monitor/run/stream',
params: { target_id: targetId, suite },
handlers,
onError,
})
}
\ No newline at end of file
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { User, Lock } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const errorMsg = ref('')
const form = reactive({
username: '',
password: '',
remember: false
})
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
async function handleLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
errorMsg.value = ''
try {
const nextPath = await userStore.login(form.username, form.password, form.remember)
const redirect = (route.query.next as string) || nextPath || '/'
router.push(redirect)
} catch (err: any) {
errorMsg.value = err.message || '登录失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<h1>问题排查助手</h1>
<p>请登录以继续访问系统</p>
</div>
<div class="login-body">
<el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleLogin">
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
:prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.remember">记住登录状态</el-checkbox>
</el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="loading"
size="large"
class="btn-login"
>
登 录
</el-button>
</el-form>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
class="error-alert"
/>
</div>
<div class="login-footer">
<p>如有账号问题,请联系管理员</p>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
background: white;
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
width: 100%;
max-width: 400px;
overflow: hidden;
}
.login-header {
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%);
color: white;
padding: 32px;
text-align: center;
h1 {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
}
p {
opacity: 0.8;
font-size: 14px;
}
}
.login-body {
padding: 32px;
}
.btn-login {
width: 100%;
margin-top: 8px;
}
.error-alert {
margin-top: 16px;
}
.login-footer {
text-align: center;
padding: 16px 32px 24px;
color: $gray-500;
font-size: 12px;
border-top: 1px solid $gray-100;
}
</style>
\ No newline at end of file
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goHome() {
router.push('/')
}
</script>
<template>
<div class="not-found-page">
<div class="not-found-content">
<div class="error-code">404</div>
<div class="error-text">页面不存在</div>
<el-button type="primary" @click="goHome">返回首页</el-button>
</div>
</div>
</template>
<style scoped lang="scss">
.not-found-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: $gray-100;
}
.not-found-content {
text-align: center;
}
.error-code {
font-size: 96px;
font-weight: 700;
color: $primary;
line-height: 1;
margin-bottom: 16px;
}
.error-text {
font-size: 18px;
color: $gray-500;
margin-bottom: 24px;
}
</style>
\ No newline at end of file
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
/// <reference types="vite/client" />
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"env.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"src/auto-imports.d.ts",
"src/components.d.ts"
]
}
{"root":["./env.d.ts","./src/auto-imports.d.ts","./src/components.d.ts","./src/main.ts","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/troubleshoot.ts","./src/api/service-monitor/index.ts","./src/api/service-monitor/notification.ts","./src/api/service-monitor/report.ts","./src/api/service-monitor/schedule.ts","./src/api/service-monitor/statistics.ts","./src/api/service-monitor/target.ts","./src/plugins/echarts.ts","./src/router/index.ts","./src/stores/app.ts","./src/stores/user.ts","./src/types/api.ts","./src/types/service-monitor.ts","./src/types/troubleshoot.ts","./src/utils/http.ts","./src/utils/markdown.ts","./src/utils/message.ts","./src/utils/sse.ts","./src/app.vue","./src/components/common/appempty.vue","./src/components/common/apploading.vue","./src/components/common/appversion.vue","./src/components/layout/appnavbar.vue","./src/components/layout/appsidebar.vue","./src/components/layout/apptopbar.vue","./src/layouts/managelayout.vue","./src/layouts/monitorlayout.vue","./src/views/login.vue","./src/views/notfound.vue","./src/views/platform.vue","./src/views/service-manage/authorization.vue","./src/views/service-manage/info.vue","./src/views/service-manage/logs.vue","./src/views/service-manage/upgrade.vue","./src/views/service-monitor/compare.vue","./src/views/service-monitor/index.vue","./src/views/service-monitor/logs.vue","./src/views/service-monitor/notification.vue","./src/views/service-monitor/reportdetail.vue","./src/views/service-monitor/reports.vue","./src/views/service-monitor/run.vue","./src/views/service-monitor/schedule.vue","./src/views/service-monitor/statistics.vue","./src/views/service-monitor/targets.vue","./src/views/troubleshoot/index.vue","./src/views/troubleshoot/logs.vue"],"version":"5.6.3"}
\ No newline at end of file
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论