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:
@@ -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>
|
||||
Generated
+1286
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "k8s-diagnosis-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user