d5d7e45ded
- 后端: FastAPI + kubectl 9大检查项(Pod/Node/Event/Deploy/Service/PVC/资源/网络) - AI Agent: OpenAI兼容接口自动分析故障根因+多轮追问对话 - 前端: Vue3 + Element Plus 仪表盘, 健康评分, 问题列表, 资源监控 - 一键启动脚本 start.sh
367 lines
16 KiB
Vue
367 lines
16 KiB
Vue
<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>
|