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
+108 -15
View File
@@ -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; }