feat: K8S智能诊断平台 - 一键诊断+AI分析+Web仪表盘

- 后端: FastAPI + kubectl 9大检查项(Pod/Node/Event/Deploy/Service/PVC/资源/网络)
- AI Agent: OpenAI兼容接口自动分析故障根因+多轮追问对话
- 前端: Vue3 + Element Plus 仪表盘, 健康评分, 问题列表, 资源监控
- 一键启动脚本 start.sh
This commit is contained in:
cnbugs
2026-07-25 10:32:26 +08:00
commit d5d7e45ded
14 changed files with 2574 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>K8S 智能诊断平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1286
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
{
"name": "k8s-diagnosis-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}
+366
View File
@@ -0,0 +1,366 @@
<template>
<div class="app-container">
<!-- 顶部导航 -->
<el-header class="app-header">
<div class="header-left">
<el-icon :size="28" color="#409eff"><Monitor /></el-icon>
<h1>K8S 智能诊断平台</h1>
<el-tag v-if="diagnosis" :type="statusTagType" size="large" effect="dark">
健康评分: {{ diagnosis.score }}/100
</el-tag>
</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">
{{ diagnosing ? '诊断中...' : '一键诊断' }}
</el-button>
<el-button type="success" :loading="analyzing" @click="runAnalysis" :icon="MagicStick" :disabled="!diagnosis">
{{ analyzing ? 'AI 分析中...' : 'AI 分析' }}
</el-button>
</div>
</el-header>
<el-main class="app-main">
<!-- 未诊断时的欢迎页 -->
<div v-if="!diagnosis && !diagnosing" class="welcome">
<el-empty description="点击「一键诊断」开始集群健康检查">
<el-button type="primary" size="large" @click="runDiagnosis" :icon="Refresh">开始诊断</el-button>
</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>
<!-- 诊断结果 -->
<template v-if="diagnosis && !diagnosing">
<!-- 概览卡片 -->
<el-row :gutter="16" class="summary-row">
<el-col :span="4">
<el-card shadow="hover" class="score-card" :class="'score-' + diagnosis.status">
<div class="score-number">{{ diagnosis.score }}</div>
<div class="score-label">健康评分</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover">
<el-statistic title="严重问题" :value="diagnosis.critical_count">
<template #suffix><el-icon color="#f56c6c"><WarningFilled /></el-icon></template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover">
<el-statistic title="警告" :value="diagnosis.warning_count">
<template #suffix><el-icon color="#e6a23c"><Warning /></el-icon></template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover">
<el-statistic title="Pod 总数" :value="podSummary.total" />
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover">
<el-statistic title="Node 就绪" :value="nodeSummary.ready + '/' + nodeSummary.total" />
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover">
<el-statistic title="诊断耗时" :value="diagnosis.elapsed_seconds + 's'" />
</el-card>
</el-col>
</el-row>
<!-- 问题列表 -->
<el-card v-if="diagnosis.issues.length" class="section-card" shadow="never">
<template #header>
<div class="card-header">
<span>🔍 发现的问题 ({{ diagnosis.issues.length }})</span>
</div>
</template>
<el-table :data="diagnosis.issues" stripe size="small" max-height="300">
<el-table-column label="级别" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.level === 'critical' ? 'danger' : 'warning'" size="small" effect="dark">
{{ row.level === 'critical' ? '严重' : '警告' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="check" label="检查项" width="160" />
<el-table-column prop="resource" label="资源" width="260" />
<el-table-column prop="message" label="问题描述" />
</el-table>
</el-card>
<!-- 各检查项详情 -->
<el-row :gutter="16">
<el-col :span="12">
<el-card class="section-card" shadow="never">
<template #header><span>📦 Pod 状态</span></template>
<div class="pod-stats">
<el-tag type="success" size="large">Running: {{ podSummary.running }}</el-tag>
<el-tag type="warning" size="large">Pending: {{ podSummary.pending }}</el-tag>
<el-tag type="danger" size="large">Failed: {{ podSummary.failed }}</el-tag>
<el-tag type="info" size="large">CrashLoop: {{ podSummary.crashloop }}</el-tag>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="section-card" shadow="never">
<template #header><span>🖥 Node 状态</span></template>
<div class="pod-stats">
<el-tag type="success" size="large">Ready: {{ nodeSummary.ready }}</el-tag>
<el-tag type="danger" size="large">NotReady: {{ nodeSummary.not_ready }}</el-tag>
</div>
<div v-if="resourceNodes.length" style="margin-top: 12px">
<div v-for="n in resourceNodes" :key="n.name" class="resource-bar">
<span class="res-name">{{ n.name }}</span>
<el-progress :percentage="n.cpu_pct" :color="barColor(n.cpu_pct)" :stroke-width="14" style="flex:1">
<span>CPU {{ n.cpu_pct }}%</span>
</el-progress>
<el-progress :percentage="n.mem_pct" :color="barColor(n.mem_pct)" :stroke-width="14" style="flex:1">
<span>MEM {{ n.mem_pct }}%</span>
</el-progress>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- Deployment 状态 -->
<el-card v-if="deployments.length" class="section-card" shadow="never">
<template #header><span>🚀 Deployment 状态</span></template>
<el-table :data="deployments" stripe size="small">
<el-table-column prop="namespace" label="命名空间" width="160" />
<el-table-column prop="name" label="名称" />
<el-table-column label="副本" width="120" align="center">
<template #default="{ row }">
<el-tag :type="row.ready >= row.desired ? 'success' : 'danger'" size="small">
{{ row.ready }}/{{ row.desired }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="updated" label="已更新" width="80" align="center" />
</el-table>
</el-card>
<!-- 异常事件 -->
<el-card v-if="warningEvents.length" class="section-card" shadow="never">
<template #header><span> 异常事件 (最近 {{ warningEvents.length }} )</span></template>
<el-table :data="warningEvents" stripe size="small" max-height="250">
<el-table-column prop="namespace" label="NS" width="120" />
<el-table-column prop="object" label="对象" width="220" />
<el-table-column prop="reason" label="原因" width="160" />
<el-table-column prop="message" label="消息" show-overflow-tooltip />
<el-table-column prop="count" label="次数" width="60" align="center" />
</el-table>
</el-card>
<!-- AI 分析结果 -->
<el-card v-if="aiAnalysis" class="section-card ai-card" shadow="never">
<template #header>
<div class="card-header">
<span>🤖 AI 智能分析</span>
<el-tag size="small" type="info">{{ aiModel }}</el-tag>
</div>
</template>
<div class="ai-content" v-html="renderedAnalysis"></div>
</el-card>
<!-- AI 对话 -->
<el-card class="section-card" shadow="never">
<template #header><span>💬 追问 AI 助手</span></template>
<div class="chat-box" ref="chatBoxRef">
<div v-for="(msg, i) in chatMessages" :key="i" :class="'chat-msg ' + msg.role">
<div class="chat-bubble" v-html="msg.role === 'assistant' ? renderMd(msg.content) : msg.content"></div>
</div>
<div v-if="chatLoading" class="chat-msg assistant">
<div class="chat-bubble"><el-icon class="is-loading"><Loading /></el-icon> 思考中...</div>
</div>
</div>
<div class="chat-input">
<el-input v-model="chatInput" placeholder="输入问题,如:为什么 Pod 一直 CrashLoopBackOff" @keyup.enter="sendChat" :disabled="chatLoading" />
<el-button type="primary" @click="sendChat" :loading="chatLoading" :icon="Promotion">发送</el-button>
</div>
</el-card>
</template>
</el-main>
</div>
</template>
<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 { ElMessage } from 'element-plus'
const API = '/api'
const namespace = ref('')
const diagnosing = ref(false)
const analyzing = ref(false)
const diagnosis = ref(null)
const aiAnalysis = ref('')
const aiModel = ref('')
const chatMessages = ref([])
const chatInput = ref('')
const chatLoading = ref(false)
const chatBoxRef = ref(null)
const statusTagType = computed(() => {
if (!diagnosis.value) return 'info'
const s = diagnosis.value.status
return s === 'healthy' ? 'success' : s === 'warning' ? 'warning' : 'danger'
})
const podSummary = computed(() => diagnosis.value?.checks?.pods?.summary || { total: 0, running: 0, pending: 0, failed: 0, crashloop: 0 })
const nodeSummary = computed(() => diagnosis.value?.checks?.nodes?.summary || { total: 0, ready: 0, not_ready: 0 })
const deployments = computed(() => diagnosis.value?.checks?.deployments?.deployments || [])
const warningEvents = computed(() => (diagnosis.value?.checks?.events?.events || []).filter(e => e.type === 'Warning').slice(-30))
const resourceNodes = computed(() => diagnosis.value?.checks?.resource_usage?.nodes || [])
const renderedAnalysis = computed(() => aiAnalysis.value ? renderMd(aiAnalysis.value) : '')
function renderMd(text) {
return marked.parse(text || '', { breaks: true })
}
function barColor(pct) {
if (pct > 90) return '#f56c6c'
if (pct > 75) return '#e6a23c'
return '#67c23a'
}
async function runDiagnosis() {
diagnosing.value = true
aiAnalysis.value = ''
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`)
} catch (e) {
ElMessage.error('诊断失败: ' + e.message)
} finally {
diagnosing.value = false
}
}
async function runAnalysis() {
analyzing.value = true
try {
const resp = await fetch(`${API}/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const data = await resp.json()
aiAnalysis.value = data.analysis || ''
aiModel.value = data.model || ''
if (!data.success) ElMessage.warning('AI 分析异常')
} catch (e) {
ElMessage.error('AI 分析失败: ' + e.message)
} finally {
analyzing.value = false
}
}
async function sendChat() {
const q = chatInput.value.trim()
if (!q || chatLoading.value) return
chatMessages.value.push({ role: 'user', content: q })
chatInput.value = ''
chatLoading.value = true
await nextTick()
scrollChat()
try {
const resp = await fetch(`${API}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: chatMessages.value.map(m => ({ role: m.role, content: m.content })),
}),
})
const data = await resp.json()
chatMessages.value.push({ role: 'assistant', content: data.reply || '无回复' })
} catch (e) {
chatMessages.value.push({ role: 'assistant', content: '请求失败: ' + e.message })
} finally {
chatLoading.value = false
await nextTick()
scrollChat()
}
}
function scrollChat() {
if (chatBoxRef.value) chatBoxRef.value.scrollTop = chatBoxRef.value.scrollHeight
}
</script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; background: #f0f2f5; }
.app-container { min-height: 100vh; display: flex; flex-direction: column; }
.app-header {
display: flex; align-items: center; justify-content: space-between;
background: #fff; padding: 0 24px; height: 64px;
box-shadow: 0 1px 4px rgba(0,0,0,.08); position: sticky; top: 0; z-index: 100;
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header-left h1 { font-size: 20px; font-weight: 600; color: #303133; }
.header-right { display: flex; align-items: center; gap: 12px; }
.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; }
.summary-row { margin-bottom: 16px; }
.score-card { text-align: center; }
.score-number { font-size: 42px; font-weight: 700; line-height: 1.2; }
.score-label { font-size: 13px; color: #909399; margin-top: 4px; }
.score-healthy .score-number { color: #67c23a; }
.score-warning .score-number { color: #e6a23c; }
.score-critical .score-number { color: #f56c6c; }
.section-card { margin-bottom: 16px; }
.card-header { display: flex; align-items: center; justify-content: space-between; }
.pod-stats { display: flex; gap: 12px; flex-wrap: wrap; }
.resource-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.res-name { width: 140px; font-size: 13px; color: #606266; text-align: right; flex-shrink: 0; }
.ai-card { border: 1px solid #d9ecff; }
.ai-content { line-height: 1.8; font-size: 14px; }
.ai-content h1, .ai-content h2, .ai-content h3 { margin: 16px 0 8px; }
.ai-content code { background: #f5f7fa; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
.ai-content pre { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px; overflow-x: auto; margin: 12px 0; }
.ai-content pre code { background: none; color: inherit; }
.ai-content ul, .ai-content ol { padding-left: 24px; }
.ai-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
.ai-content th, .ai-content td { border: 1px solid #ebeef5; padding: 8px 12px; text-align: left; }
.chat-box { max-height: 400px; overflow-y: auto; padding: 12px 0; display: flex; flex-direction: column; gap: 12px; }
.chat-msg { display: flex; }
.chat-msg.user { justify-content: flex-end; }
.chat-msg.assistant { justify-content: flex-start; }
.chat-bubble {
max-width: 80%; padding: 10px 16px; border-radius: 12px; font-size: 14px; line-height: 1.6;
}
.chat-msg.user .chat-bubble { background: #409eff; color: #fff; border-bottom-right-radius: 4px; }
.chat-msg.assistant .chat-bubble { background: #f4f4f5; color: #303133; border-bottom-left-radius: 4px; }
.chat-bubble pre { background: #1e1e1e; color: #d4d4d4; padding: 12px; border-radius: 6px; overflow-x: auto; margin: 8px 0; }
.chat-bubble code { background: rgba(0,0,0,.06); padding: 1px 4px; border-radius: 3px; font-size: 13px; }
.chat-bubble pre code { background: none; }
.chat-input { display: flex; gap: 8px; margin-top: 12px; }
</style>
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
const app = createApp(App)
app.use(ElementPlus, { locale: zhCn })
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:8900',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
},
})