Files
k8smanager-cli/frontend/src/App.vue
T
cnbugs d5d7e45ded feat: K8S智能诊断平台 - 一键诊断+AI分析+Web仪表盘
- 后端: FastAPI + kubectl 9大检查项(Pod/Node/Event/Deploy/Service/PVC/资源/网络)
- AI Agent: OpenAI兼容接口自动分析故障根因+多轮追问对话
- 前端: Vue3 + Element Plus 仪表盘, 健康评分, 问题列表, 资源监控
- 一键启动脚本 start.sh
2026-07-25 10:32:26 +08:00

367 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>