feat: 诊断过程实时进度展示 (SSE流式推送)

- 新增 GET /api/diagnose/stream SSE接口, 每完成一项检查实时推送
- 前端: 诊断中显示步骤进度条+每项检查状态(运行中/完成/问题数)
- 解决诊断过程'卡着'无反馈的体验问题
- 保留 POST /api/diagnose 兼容旧接口
This commit is contained in:
cnbugs
2026-07-25 10:56:02 +08:00
parent f3c281dda4
commit 7a271f24d4
3 changed files with 228 additions and 30 deletions
+89 -4
View File
@@ -1,14 +1,22 @@
"""K8S 智能诊断平台 - FastAPI 主应用""" """K8S 智能诊断平台 - FastAPI 主应用"""
import os import os
import json
import asyncio
from datetime import datetime from datetime import datetime
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from backend.config import PORT from backend.config import PORT
from backend.diagnosis import run_full_diagnosis, build_diagnosis_report, ALL_CHECKS from backend.diagnosis import (
run_full_diagnosis, build_diagnosis_report, ALL_CHECKS,
check_cluster_info, check_pods, check_nodes, check_events,
check_deployments, check_services, check_pvc, check_resource_usage,
check_network_policies,
)
from backend.ai_agent import analyze_with_ai, chat_with_ai from backend.ai_agent import analyze_with_ai, chat_with_ai
app = FastAPI(title="K8S 智能诊断平台", version="1.0.0") app = FastAPI(title="K8S 智能诊断平台", version="1.0.0")
@@ -24,6 +32,19 @@ app.add_middleware(
_last_diagnosis: Optional[dict] = None _last_diagnosis: Optional[dict] = None
_last_report: str = "" _last_report: str = ""
# 诊断项执行顺序 (带中文标题)
CHECK_ORDER = [
("cluster_info", "集群信息", check_cluster_info),
("nodes", "Node 状态检查", check_nodes),
("pods", "Pod 状态检查", check_pods),
("deployments", "Deployment 状态检查", check_deployments),
("services", "Service/Endpoint 检查", check_services),
("events", "异常事件检查", check_events),
("pvc", "PVC 存储检查", check_pvc),
("resource_usage", "资源使用率", check_resource_usage),
("network", "网络策略检查", check_network_policies),
]
class DiagnoseRequest(BaseModel): class DiagnoseRequest(BaseModel):
namespace: str = "" namespace: str = ""
@@ -48,15 +69,79 @@ async def list_checks():
"""列出所有可用诊断项""" """列出所有可用诊断项"""
return { return {
"checks": [ "checks": [
{"name": name, "title": fn.__doc__ or name} {"name": name, "title": title}
for name, fn in ALL_CHECKS.items() for name, title, _ in CHECK_ORDER
] ]
} }
@app.get("/api/diagnose/stream")
async def diagnose_stream(namespace: str = ""):
"""SSE 流式诊断 - 实时推送每一步进度"""
global _last_diagnosis, _last_report
async def event_generator():
start = datetime.now()
results = {}
all_issues = []
total = len(CHECK_ORDER)
for idx, (name, title, fn) in enumerate(CHECK_ORDER, 1):
# 推送: 开始检查
yield f"data: {json.dumps({'type': 'progress', 'step': idx, 'total': total, 'name': name, 'title': title, 'status': 'running'}, ensure_ascii=False)}\n\n"
try:
if name in ("pods", "events", "deployments", "services", "pvc", "network"):
result = await fn(namespace)
else:
result = await fn()
results[name] = result
issue_count = len(result.get("issues", []))
for issue in result.get("issues", []):
issue["check"] = title
all_issues.append(issue)
except Exception as e:
results[name] = {"name": name, "title": title, "error": str(e), "issues": []}
issue_count = -1
# 推送: 完成检查
yield f"data: {json.dumps({'type': 'progress', 'step': idx, 'total': total, 'name': name, 'title': title, 'status': 'done', 'issues': issue_count}, ensure_ascii=False)}\n\n"
# 汇总
critical = [i for i in all_issues if i["level"] == "critical"]
warning = [i for i in all_issues if i["level"] == "warning"]
elapsed = (datetime.now() - start).total_seconds()
score = max(0, 100 - len(critical) * 15 - len(warning) * 5)
status = "healthy" if score >= 90 else ("warning" if score >= 60 else "critical")
diagnosis = {
"timestamp": start.isoformat(),
"elapsed_seconds": round(elapsed, 2),
"namespace": namespace or "all",
"score": score,
"status": status,
"critical_count": len(critical),
"warning_count": len(warning),
"issues": all_issues,
"checks": results,
}
_last_diagnosis = diagnosis
_last_report = build_diagnosis_report(diagnosis)
# 推送: 最终结果
yield f"data: {json.dumps({'type': 'complete', 'diagnosis': diagnosis}, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.post("/api/diagnose") @app.post("/api/diagnose")
async def diagnose(req: DiagnoseRequest): async def diagnose(req: DiagnoseRequest):
"""执行一键诊断""" """执行一键诊断 (非流式,兼容旧接口)"""
global _last_diagnosis, _last_report global _last_diagnosis, _last_report
_last_diagnosis = await run_full_diagnosis(namespace=req.namespace, checks=req.checks) _last_diagnosis = await run_full_diagnosis(namespace=req.namespace, checks=req.checks)
_last_report = build_diagnosis_report(_last_diagnosis) _last_report = build_diagnosis_report(_last_diagnosis)
+108 -15
View File
@@ -11,10 +11,10 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<el-input v-model="namespace" placeholder="命名空间 (留空=全部)" clearable style="width: 200px" /> <el-input v-model="namespace" placeholder="命名空间 (留空=全部)" clearable style="width: 200px" />
<el-button type="primary" :loading="diagnosing" @click="runDiagnosis" :icon="Refresh"> <el-button type="primary" :loading="diagnosing" @click="runDiagnosis" :icon="Refresh" :disabled="diagnosing">
{{ diagnosing ? '诊断中...' : '一键诊断' }} {{ diagnosing ? '诊断中...' : '一键诊断' }}
</el-button> </el-button>
<el-button type="success" :loading="analyzing" @click="runAnalysis" :icon="MagicStick" :disabled="!diagnosis"> <el-button type="success" :loading="analyzing" @click="runAnalysis" :icon="MagicStick" :disabled="!diagnosis || diagnosing">
{{ analyzing ? 'AI 分析中...' : 'AI 分析' }} {{ analyzing ? 'AI 分析中...' : 'AI 分析' }}
</el-button> </el-button>
</div> </div>
@@ -28,10 +28,34 @@
</el-empty> </el-empty>
</div> </div>
<!-- 诊断中 --> <!-- 诊断中 - 实时进度 -->
<div v-if="diagnosing" class="loading-box"> <div v-if="diagnosing" class="progress-box">
<el-icon class="is-loading" :size="48" color="#409eff"><Loading /></el-icon> <el-card shadow="never" class="progress-card">
<p>正在执行集群诊断请稍候...</p> <template #header>
<div class="card-header">
<span>🔍 正在执行集群诊断</span>
<el-tag size="small" type="info">{{ progressStep }}/{{ progressTotal }}</el-tag>
</div>
</template>
<el-progress :percentage="progressPercent" :stroke-width="10" :color="'#409eff'" style="margin-bottom: 20px" />
<div class="progress-steps">
<div v-for="item in progressList" :key="item.name" class="progress-item" :class="'status-' + item.status">
<span class="step-icon">
<el-icon v-if="item.status === 'running'" class="is-loading" color="#409eff"><Loading /></el-icon>
<el-icon v-else-if="item.status === 'done'" color="#67c23a"><CircleCheckFilled /></el-icon>
<el-icon v-else color="#c0c4cc"><CircleClose /></el-icon>
</span>
<span class="step-title">{{ item.title }}</span>
<span v-if="item.status === 'done'" class="step-result">
<el-tag v-if="item.issues > 0" size="small" :type="item.issues > 0 ? 'warning' : 'success'">
{{ item.issues }} 个问题
</el-tag>
<el-tag v-else size="small" type="success">正常</el-tag>
</span>
<span v-if="item.status === 'running'" class="step-running">检查中...</span>
</div>
</div>
</el-card>
</div> </div>
<!-- 诊断结果 --> <!-- 诊断结果 -->
@@ -195,7 +219,7 @@
<script setup> <script setup>
import { ref, computed, nextTick } from 'vue' import { ref, computed, nextTick } from 'vue'
import { marked } from 'marked' import { marked } from 'marked'
import { Refresh, MagicStick, Loading, WarningFilled, Warning, Promotion, Monitor } from '@element-plus/icons-vue' import { Refresh, MagicStick, Loading, WarningFilled, Warning, Promotion, Monitor, CircleCheckFilled, CircleClose } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const API = '/api' const API = '/api'
@@ -211,6 +235,16 @@ const chatInput = ref('')
const chatLoading = ref(false) const chatLoading = ref(false)
const chatBoxRef = ref(null) const chatBoxRef = ref(null)
// 诊断进度
const progressList = ref([])
const progressStep = ref(0)
const progressTotal = ref(9)
const progressPercent = computed(() => {
if (progressTotal.value === 0) return 0
return Math.round((progressStep.value / progressTotal.value) * 100)
})
const statusTagType = computed(() => { const statusTagType = computed(() => {
if (!diagnosis.value) return 'info' if (!diagnosis.value) return 'info'
const s = diagnosis.value.status const s = diagnosis.value.status
@@ -238,14 +272,59 @@ function barColor(pct) {
async function runDiagnosis() { async function runDiagnosis() {
diagnosing.value = true diagnosing.value = true
aiAnalysis.value = '' aiAnalysis.value = ''
diagnosis.value = null
progressList.value = []
progressStep.value = 0
progressTotal.value = 9
try { try {
const resp = await fetch(`${API}/diagnose`, { const params = new URLSearchParams()
method: 'POST', if (namespace.value) params.set('namespace', namespace.value)
headers: { 'Content-Type': 'application/json' }, const url = `${API}/diagnose/stream?${params.toString()}`
body: JSON.stringify({ namespace: namespace.value }),
}) const resp = await fetch(url)
diagnosis.value = await resp.json() const reader = resp.body.getReader()
ElMessage.success(`诊断完成,评分 ${diagnosis.value.score}/100`) const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const jsonStr = line.slice(6)
if (!jsonStr) continue
try {
const msg = JSON.parse(jsonStr)
if (msg.type === 'progress') {
progressTotal.value = msg.total
progressStep.value = msg.step
if (msg.status === 'running') {
progressList.value.push({ name: msg.name, title: msg.title, status: 'running', issues: 0 })
} else if (msg.status === 'done') {
const item = progressList.value.find(i => i.name === msg.name)
if (item) {
item.status = 'done'
item.issues = msg.issues
}
}
} else if (msg.type === 'complete') {
diagnosis.value = msg.diagnosis
ElMessage.success(`诊断完成,评分 ${msg.diagnosis.score}/100`)
}
} catch (e) {
// ignore parse errors on partial data
}
}
}
} catch (e) { } catch (e) {
ElMessage.error('诊断失败: ' + e.message) ElMessage.error('诊断失败: ' + e.message)
} finally { } finally {
@@ -322,7 +401,21 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC'
.app-main { flex: 1; padding: 20px 24px; max-width: 1400px; margin: 0 auto; width: 100%; } .app-main { flex: 1; padding: 20px 24px; max-width: 1400px; margin: 0 auto; width: 100%; }
.welcome { display: flex; justify-content: center; align-items: center; min-height: 400px; } .welcome { display: flex; justify-content: center; align-items: center; min-height: 400px; }
.loading-box { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 300px; gap: 16px; color: #909399; }
/* 诊断进度 */
.progress-box { display: flex; justify-content: center; padding-top: 40px; }
.progress-card { width: 600px; }
.progress-steps { display: flex; flex-direction: column; gap: 4px; }
.progress-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 6px; transition: background .2s;
}
.progress-item.status-running { background: #ecf5ff; }
.progress-item.status-done { background: #f0f9eb; }
.step-icon { display: flex; align-items: center; width: 20px; }
.step-title { flex: 1; font-size: 14px; color: #303133; }
.step-result { font-size: 12px; }
.step-running { font-size: 12px; color: #409eff; }
.summary-row { margin-bottom: 16px; } .summary-row { margin-bottom: 16px; }
.score-card { text-align: center; } .score-card { text-align: center; }
+31 -11
View File
@@ -6,52 +6,72 @@ cd "$(dirname "$0")"
echo "=========================================" echo "========================================="
echo " K8S 智能诊断平台 - 环境安装" echo " K8S 智能诊断平台 - 环境安装"
echo "=========================================" echo "========================================="
echo ""
# 1. 检查 Node.js # 1. 检查 Node.js
echo "[1/5] 检查 Node.js ..."
if ! command -v node &>/dev/null; then if ! command -v node &>/dev/null; then
echo "📦 安装 Node.js 20.x ..." echo " → 未找到 Node.js,正在安装 20.x ..."
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs apt-get install -y nodejs
fi fi
echo "✅ Node.js: $(node -v)" echo " ✅ Node.js $(node -v), npm $(npm -v)"
echo "✅ npm: $(npm -v)" echo ""
# 2. 安装前端依赖 # 2. 安装前端依赖
echo "📦 安装前端依赖..." echo "[2/5] 安装前端依赖 (npm install) ..."
echo " → 如果速度慢,可 Ctrl+C 后手动执行:"
echo " cd frontend && npm install --include=dev --registry=https://registry.npmmirror.com"
echo ""
cd frontend cd frontend
npm install --include=dev npm install --include=dev
cd .. cd ..
echo " ✅ 前端依赖安装完成"
echo ""
# 3. 安装 Python 依赖 # 3. 安装 Python 依赖
echo "📦 安装 Python 依赖..." echo "[3/5] 安装 Python 依赖 (pip3 install) ..."
if command -v pip3 &>/dev/null; then if command -v pip3 &>/dev/null; then
pip3 install fastapi uvicorn kubernetes httpx python-dotenv pip3 install fastapi uvicorn kubernetes httpx python-dotenv
elif command -v pip &>/dev/null; then elif command -v pip &>/dev/null; then
pip install fastapi uvicorn kubernetes httpx python-dotenv pip install fastapi uvicorn kubernetes httpx python-dotenv
else else
echo " → pip 未找到,正在安装 python3-pip ..."
apt-get install -y python3-pip apt-get install -y python3-pip
pip3 install fastapi uvicorn kubernetes httpx python-dotenv pip3 install fastapi uvicorn kubernetes httpx python-dotenv
fi fi
echo "✅ Python 依赖安装完成" echo " ✅ Python 依赖安装完成"
echo ""
# 4. 构建前端 # 4. 构建前端
echo "📦 构建前端..." echo "[4/5] 构建前端 (vite build) ..."
cd frontend && npm run build && cd .. cd frontend && npm run build && cd ..
echo "✅ 前端构建完成" echo " ✅ 前端构建完成 → frontend/dist/"
echo ""
# 5. 创建 .env (如果不存在) # 5. 创建 .env
echo "[5/5] 检查配置文件 ..."
if [ ! -f .env ]; then if [ ! -f .env ]; then
cat > .env << 'EOF' cat > .env << 'EOF'
# AI 分析接口 (OpenAI 兼容格式)
AI_API_BASE=http://localhost:8648/v1 AI_API_BASE=http://localhost:8648/v1
AI_API_KEY=hermes AI_API_KEY=hermes
AI_MODEL=qwen3.8-max-preview AI_MODEL=qwen3.8-max-preview
# 服务端口
PORT=8900 PORT=8900
# Kubeconfig 路径 (留空使用默认 ~/.kube/config)
KUBECONFIG_PATH= KUBECONFIG_PATH=
EOF EOF
echo "✅ 已创建 .env 配置文件 (请按需修改 AI 接口配置)" echo " ✅ 已创建 .env (请按需修改 AI_API_BASE 和 AI_API_KEY)"
else
echo " ✅ .env 已存在,跳过"
fi fi
echo "" echo ""
echo "=========================================" echo "========================================="
echo " 安装完成!运行 ./start.sh 启动服务" echo " 安装完成!"
echo " 启动服务: ./start.sh"
echo " 访问地址: http://localhost:8900"
echo "=========================================" echo "========================================="