feat: 诊断过程实时进度展示 (SSE流式推送)
- 新增 GET /api/diagnose/stream SSE接口, 每完成一项检查实时推送 - 前端: 诊断中显示步骤进度条+每项检查状态(运行中/完成/问题数) - 解决诊断过程'卡着'无反馈的体验问题 - 保留 POST /api/diagnose 兼容旧接口
This commit is contained in:
+89
-4
@@ -1,14 +1,22 @@
|
||||
"""K8S 智能诊断平台 - FastAPI 主应用"""
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
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
|
||||
|
||||
app = FastAPI(title="K8S 智能诊断平台", version="1.0.0")
|
||||
@@ -24,6 +32,19 @@ app.add_middleware(
|
||||
_last_diagnosis: Optional[dict] = None
|
||||
_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):
|
||||
namespace: str = ""
|
||||
@@ -48,15 +69,79 @@ async def list_checks():
|
||||
"""列出所有可用诊断项"""
|
||||
return {
|
||||
"checks": [
|
||||
{"name": name, "title": fn.__doc__ or name}
|
||||
for name, fn in ALL_CHECKS.items()
|
||||
{"name": name, "title": title}
|
||||
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")
|
||||
async def diagnose(req: DiagnoseRequest):
|
||||
"""执行一键诊断"""
|
||||
"""执行一键诊断 (非流式,兼容旧接口)"""
|
||||
global _last_diagnosis, _last_report
|
||||
_last_diagnosis = await run_full_diagnosis(namespace=req.namespace, checks=req.checks)
|
||||
_last_report = build_diagnosis_report(_last_diagnosis)
|
||||
|
||||
+108
-15
@@ -11,10 +11,10 @@
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<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 ? '诊断中...' : '一键诊断' }}
|
||||
</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 分析' }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -28,10 +28,34 @@
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 诊断中 -->
|
||||
<div v-if="diagnosing" class="loading-box">
|
||||
<el-icon class="is-loading" :size="48" color="#409eff"><Loading /></el-icon>
|
||||
<p>正在执行集群诊断,请稍候...</p>
|
||||
<!-- 诊断中 - 实时进度 -->
|
||||
<div v-if="diagnosing" class="progress-box">
|
||||
<el-card shadow="never" class="progress-card">
|
||||
<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>
|
||||
|
||||
<!-- 诊断结果 -->
|
||||
@@ -195,7 +219,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
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'
|
||||
|
||||
const API = '/api'
|
||||
@@ -211,6 +235,16 @@ const chatInput = ref('')
|
||||
const chatLoading = ref(false)
|
||||
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(() => {
|
||||
if (!diagnosis.value) return 'info'
|
||||
const s = diagnosis.value.status
|
||||
@@ -238,14 +272,59 @@ function barColor(pct) {
|
||||
async function runDiagnosis() {
|
||||
diagnosing.value = true
|
||||
aiAnalysis.value = ''
|
||||
diagnosis.value = null
|
||||
progressList.value = []
|
||||
progressStep.value = 0
|
||||
progressTotal.value = 9
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API}/diagnose`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ namespace: namespace.value }),
|
||||
})
|
||||
diagnosis.value = await resp.json()
|
||||
ElMessage.success(`诊断完成,评分 ${diagnosis.value.score}/100`)
|
||||
const params = new URLSearchParams()
|
||||
if (namespace.value) params.set('namespace', namespace.value)
|
||||
const url = `${API}/diagnose/stream?${params.toString()}`
|
||||
|
||||
const resp = await fetch(url)
|
||||
const reader = resp.body.getReader()
|
||||
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) {
|
||||
ElMessage.error('诊断失败: ' + e.message)
|
||||
} 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%; }
|
||||
|
||||
.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; }
|
||||
.score-card { text-align: center; }
|
||||
|
||||
@@ -6,52 +6,72 @@ cd "$(dirname "$0")"
|
||||
echo "========================================="
|
||||
echo " K8S 智能诊断平台 - 环境安装"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# 1. 检查 Node.js
|
||||
echo "[1/5] 检查 Node.js ..."
|
||||
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 -
|
||||
apt-get install -y nodejs
|
||||
fi
|
||||
echo "✅ Node.js: $(node -v)"
|
||||
echo "✅ npm: $(npm -v)"
|
||||
echo " ✅ Node.js $(node -v), npm $(npm -v)"
|
||||
echo ""
|
||||
|
||||
# 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
|
||||
npm install --include=dev
|
||||
cd ..
|
||||
echo " ✅ 前端依赖安装完成"
|
||||
echo ""
|
||||
|
||||
# 3. 安装 Python 依赖
|
||||
echo "📦 安装 Python 依赖..."
|
||||
echo "[3/5] 安装 Python 依赖 (pip3 install) ..."
|
||||
if command -v pip3 &>/dev/null; then
|
||||
pip3 install fastapi uvicorn kubernetes httpx python-dotenv
|
||||
elif command -v pip &>/dev/null; then
|
||||
pip install fastapi uvicorn kubernetes httpx python-dotenv
|
||||
else
|
||||
echo " → pip 未找到,正在安装 python3-pip ..."
|
||||
apt-get install -y python3-pip
|
||||
pip3 install fastapi uvicorn kubernetes httpx python-dotenv
|
||||
fi
|
||||
echo " ✅ Python 依赖安装完成"
|
||||
echo ""
|
||||
|
||||
# 4. 构建前端
|
||||
echo "📦 构建前端..."
|
||||
echo "[4/5] 构建前端 (vite build) ..."
|
||||
cd frontend && npm run build && cd ..
|
||||
echo "✅ 前端构建完成"
|
||||
echo " ✅ 前端构建完成 → frontend/dist/"
|
||||
echo ""
|
||||
|
||||
# 5. 创建 .env (如果不存在)
|
||||
# 5. 创建 .env
|
||||
echo "[5/5] 检查配置文件 ..."
|
||||
if [ ! -f .env ]; then
|
||||
cat > .env << 'EOF'
|
||||
# AI 分析接口 (OpenAI 兼容格式)
|
||||
AI_API_BASE=http://localhost:8648/v1
|
||||
AI_API_KEY=hermes
|
||||
AI_MODEL=qwen3.8-max-preview
|
||||
|
||||
# 服务端口
|
||||
PORT=8900
|
||||
|
||||
# Kubeconfig 路径 (留空使用默认 ~/.kube/config)
|
||||
KUBECONFIG_PATH=
|
||||
EOF
|
||||
echo "✅ 已创建 .env 配置文件 (请按需修改 AI 接口配置)"
|
||||
echo " ✅ 已创建 .env (请按需修改 AI_API_BASE 和 AI_API_KEY)"
|
||||
else
|
||||
echo " ✅ .env 已存在,跳过"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " 安装完成!运行 ./start.sh 启动服务"
|
||||
echo " ✅ 安装完成!"
|
||||
echo " 启动服务: ./start.sh"
|
||||
echo " 访问地址: http://localhost:8900"
|
||||
echo "========================================="
|
||||
|
||||
Reference in New Issue
Block a user