Files
k8smanager-cli/frontend/src/App.vue
T
cnbugs 32bea0d4ea feat: AI 诊断 Agent 支持执行系统诊断 shell 命令
扩展 Agent 命令工具,除 kubectl 外新增节点宿主机系统诊断命令支持:

- agent_tools.py: 新增 shell 命令白名单分类器 (classify_shell_command)
  - 只读白名单: systemctl status/is-active、journalctl、crictl ps/logs、
    docker ps/logs、df、free、ss、ip addr、ps、mount、lsmod、dmesg、
    ping、nslookup、cat (限 /proc /sys /etc/kubernetes 等安全路径) 等 30+ 命令
  - 写白名单: systemctl restart/stop/start/reload 等需人工审批
  - 统一分发器 classify_agent_command 按命令前缀路由
  - 统一执行入口 execute_command 走 subprocess_exec (无 shell 注入风险)
- ai_agent.py: 更新 AGENT_SYSTEM_PROMPT 告知 AI 两类可用工具及安全规则
  - 三个调用点 (初始探测/分类/执行) 切换到统一接口
- main.py: 审批执行处改用 execute_command
- tests: 新增 19 个用例覆盖 shell 命令分类、混合执行、白名单拒绝
  - 全部 44 个测试通过
- 前端/README: 文案与文档补充 shell 命令支持说明

安全策略不变: 禁止 shell 操作符/管道/重定向/多命令拼接,
非白名单命令 (rm/vi/apt 等) 直接拒绝。
2026-07-25 13:38:48 +08:00

961 lines
43 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" :disabled="diagnosing">
{{ diagnosing ? '诊断中...' : '一键诊断' }}
</el-button>
<el-button type="success" :loading="analyzing" @click="runAnalysis" :icon="MagicStick" :disabled="!diagnosis || diagnosing || agentRunning">
{{ analyzing ? 'AI 分析中...' : 'AI 分析' }}
</el-button>
<el-radio-group v-model="agentExecutionMode" :disabled="agentRunning" class="agent-mode-switch">
<el-radio-button value="manual">手动执行命令</el-radio-button>
<el-radio-button value="ai">AI 执行命令</el-radio-button>
</el-radio-group>
<el-button @click="openHistory" :disabled="diagnosing || analyzing || agentRunning">历史记录</el-button>
<el-button type="warning" :loading="agentRunning" @click="runAgentDiagnosis" :disabled="!diagnosis || diagnosing || analyzing">
{{ agentRunning ? 'Agent 诊断中...' : '自主诊断' }}
</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="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>
<!-- 集群连接失败 -->
<div v-if="diagnosis && !diagnosing && diagnosis.connected === false" class="conn-error-box">
<el-card shadow="never" class="conn-error-card">
<div class="conn-error-icon">
<el-icon :size="64" color="#f56c6c"><CircleCloseFilled /></el-icon>
</div>
<h2>无法连接到 Kubernetes 集群</h2>
<div class="conn-error-detail">
<el-alert type="error" :closable="false" show-icon>
<template #title>错误信息</template>
<pre class="conn-error-pre">{{ diagnosis.connectivity_error }}</pre>
</el-alert>
<el-alert v-if="diagnosis.connectivity_suggestion" type="warning" :closable="false" show-icon style="margin-top: 12px">
<template #title>排查建议</template>
<div>{{ diagnosis.connectivity_suggestion }}</div>
</el-alert>
</div>
<div class="conn-error-actions">
<el-button type="primary" @click="runDiagnosis" :icon="Refresh">重新诊断</el-button>
</div>
</el-card>
</div>
<!-- 诊断结果 -->
<template v-if="diagnosis && !diagnosing && diagnosis.connected !== false">
<!-- 概览卡片 -->
<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>
</template>
<!-- AI 分析结果 (连接成功或失败都显示) -->
<el-card v-if="diagnosis && !diagnosing && (aiAnalysis || analyzing)" class="section-card ai-card" shadow="never">
<template #header>
<div class="card-header">
<span>🤖 AI 智能分析</span>
<div class="ai-header-meta">
<el-tag v-if="analyzing" size="small" type="primary" effect="light">分析中</el-tag>
<el-tag v-else-if="aiAnalysis" size="small" type="success" effect="light">已完成</el-tag>
<el-tag v-if="aiModel" size="small" type="info">{{ aiModel }}</el-tag>
</div>
</div>
</template>
<div v-if="analysisProcess.length" class="analysis-process">
<div class="process-heading">
<span>分析过程</span>
<span>{{ analysisCompleted }}/{{ analysisProcess.length }}</span>
</div>
<el-progress
:percentage="analysisProgressPercent"
:stroke-width="6"
:show-text="false"
:status="analysisProgressStatus"
/>
<div class="analysis-step-list">
<div v-for="item in analysisProcess" :key="item.step" class="analysis-step" :class="'status-' + item.status">
<span class="analysis-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>
<div class="analysis-step-body">
<strong>{{ item.title }}</strong>
<span>{{ item.detail }}</span>
</div>
<el-tag v-if="item.status === 'running'" size="small" type="primary">处理中</el-tag>
<el-tag v-else-if="item.status === 'done'" size="small" type="success">完成</el-tag>
<el-tag v-else size="small" type="info">等待</el-tag>
</div>
</div>
</div>
<el-divider v-if="analysisProcess.length && (aiAnalysis || analyzing)" />
<div v-if="analyzing && !aiAnalysis" class="ai-loading">
<el-icon class="is-loading" color="#409eff"><Loading /></el-icon>
<span>AI 正在生成分析结论请稍候...</span>
</div>
<div class="ai-content" v-html="renderedAnalysis"></div>
</el-card>
<!-- 自主诊断 Agent -->
<el-card v-if="diagnosis && !diagnosing && (agentRunning || agentEvents.length)" class="section-card agent-card" shadow="never">
<template #header>
<div class="card-header">
<span>🧠 AI Agent 自主诊断</span>
<div class="ai-header-meta">
<el-tag :type="activeAgentMode === 'ai' ? 'success' : 'info'" size="small">
{{ activeAgentMode === 'ai' ? 'AI 执行模式' : '手动执行模式' }}
</el-tag>
<el-tag v-if="activeAgentMode === 'ai'" type="warning" size="small">修改命令人工确认</el-tag>
</div>
</div>
</template>
<el-alert
:title="activeAgentMode === 'ai'
? 'AI 执行模式:Agent 自动执行只读 kubectl 和系统诊断命令 (systemctl status、journalctl、crictl ps、df 等),修改或交互命令仍需人工批准。'
: '手动执行模式:Agent 只生成安全的诊断命令,不会在服务器上执行,请复制命令到终端手动运行。'"
type="info"
:closable="false"
show-icon
class="agent-alert"
/>
<div class="agent-timeline">
<div v-for="(event, index) in agentEvents" :key="index" class="agent-event" :class="'event-' + event.type">
<div class="agent-event-title">
<el-icon v-if="event.type === 'thinking'" class="is-loading" color="#409eff"><Loading /></el-icon>
<el-icon v-else-if="event.type === 'result' || event.type === 'final'" color="#67c23a"><CircleCheckFilled /></el-icon>
<el-icon v-else-if="event.type === 'approval_required'" color="#e6a23c"><WarningFilled /></el-icon>
<el-icon v-else color="#909399"><Monitor /></el-icon>
<strong>{{ agentEventTitle(event) }}</strong>
<el-tag v-if="event.mode" :type="event.mode === 'read' ? 'success' : 'warning'" size="small">
{{ event.mode === 'read' ? '只读' : '需审批' }}
</el-tag>
</div>
<p v-if="event.message" class="agent-event-message">{{ event.message }}</p>
<p v-if="event.reason" class="agent-event-message">目的{{ event.reason }}</p>
<pre v-if="event.command" class="agent-command">{{ event.command }}</pre>
<pre v-if="event.stdout || event.stderr" class="agent-output">{{ event.stdout || event.stderr }}</pre>
<div v-if="event.type === 'final'" class="ai-content" v-html="renderMd(event.content)"></div>
<div v-if="event.type === 'manual_command'" class="manual-actions">
<el-button type="primary" plain @click="copyAgentCommand(event.command)">复制命令</el-button>
<span>请在你的终端中执行Agent 不会自动运行该命令</span>
</div>
<div v-if="event.type === 'approval_required' && !event.resolved" class="approval-actions">
<el-button type="danger" @click="resolveApproval(event, true)" :loading="event.executing">确认执行</el-button>
<el-button @click="resolveApproval(event, false)" :disabled="event.executing">拒绝</el-button>
</div>
<el-alert v-if="event.approvalResult" :title="event.approvalResult" :type="event.approvalSuccess ? 'success' : 'info'" :closable="false" />
</div>
<div v-if="agentRunning" class="agent-running">
<el-icon class="is-loading" color="#409eff"><Loading /></el-icon>
<span>Agent 正在分析命令结果并规划下一步...</span>
</div>
</div>
</el-card>
<!-- AI 对话 (连接成功或失败都显示) -->
<el-card v-if="diagnosis && !diagnosing" 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 && !chatMessages.length" 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>
</el-main>
<el-drawer v-model="historyVisible" title="历史诊断与处理记录" size="75%" @open="loadHistory">
<div class="history-toolbar">
<el-select v-model="historyStatus" placeholder="全部状态" clearable style="width: 150px" @change="loadHistory">
<el-option label="健康" value="healthy" />
<el-option label="警告" value="warning" />
<el-option label="严重" value="critical" />
</el-select>
<el-input v-model="historyNamespace" placeholder="命名空间" clearable style="width: 180px" @keyup.enter="loadHistory" />
<el-button type="primary" @click="loadHistory" :loading="historyLoading">查询</el-button>
</div>
<el-table :data="historyItems" stripe highlight-current-row @row-click="loadHistoryDetail" v-loading="historyLoading">
<el-table-column prop="created_at" label="诊断时间" min-width="180" />
<el-table-column prop="namespace" label="命名空间" min-width="110" />
<el-table-column prop="score" label="评分" width="80" />
<el-table-column label="状态" width="90">
<template #default="{ row }"><el-tag :type="historyStatusType(row.status)">{{ historyStatusText(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="critical_count" label="严重" width="75" />
<el-table-column prop="warning_count" label="警告" width="75" />
<el-table-column prop="run_count" label="处理记录" width="100" />
<el-table-column label="操作" width="100">
<template #default="{ row }"><el-button link type="primary" @click.stop="loadHistoryDetail(row)">查看详情</el-button></template>
</el-table-column>
</el-table>
<div v-if="historyDetail" class="history-detail">
<el-divider content-position="left">诊断详情</el-divider>
<el-descriptions :column="4" border>
<el-descriptions-item label="时间">{{ historyDetail.created_at }}</el-descriptions-item>
<el-descriptions-item label="命名空间">{{ historyDetail.diagnosis.namespace }}</el-descriptions-item>
<el-descriptions-item label="健康评分">{{ historyDetail.diagnosis.score }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ historyStatusText(historyDetail.diagnosis.status) }}</el-descriptions-item>
</el-descriptions>
<el-collapse class="history-collapse">
<el-collapse-item title="完整诊断报告" name="report">
<pre class="history-report">{{ historyDetail.report }}</pre>
</el-collapse-item>
<el-collapse-item v-for="run in historyDetail.runs" :key="run.id" :name="run.id">
<template #title>
<span>{{ runKindText(run.kind) }} · {{ run.started_at }} · {{ runStatusText(run.status) }}</span>
</template>
<el-descriptions :column="3" size="small" border>
<el-descriptions-item label="模式">{{ run.mode || '-' }}</el-descriptions-item>
<el-descriptions-item label="问题">{{ run.question || '-' }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ run.finished_at || '-' }}</el-descriptions-item>
</el-descriptions>
<div v-for="(event, index) in run.events" :key="index" class="history-event">
<div class="history-event-head"><el-tag size="small">{{ event.type }}</el-tag><span>{{ event.created_at }}</span></div>
<pre>{{ formatHistoryEvent(event) }}</pre>
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { marked } from 'marked'
import { Refresh, MagicStick, Loading, WarningFilled, Warning, Promotion, Monitor, CircleCheckFilled, CircleClose, CircleCloseFilled } 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 agentRunning = ref(false)
const agentExecutionMode = ref('manual')
const activeAgentMode = ref('manual')
const agentEvents = ref([])
const diagnosis = ref(null)
const aiAnalysis = ref('')
const aiModel = ref('')
const analysisProcess = ref([])
const historyVisible = ref(false)
const historyLoading = ref(false)
const historyItems = ref([])
const historyDetail = ref(null)
const historyStatus = ref('')
const historyNamespace = ref('')
const chatMessages = ref([])
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
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) : '')
const analysisCompleted = computed(() => analysisProcess.value.filter(item => item.status === 'done').length)
const analysisProgressPercent = computed(() => {
if (!analysisProcess.value.length) return 0
return Math.round((analysisCompleted.value / analysisProcess.value.length) * 100)
})
const analysisProgressStatus = computed(() => analysisProgressPercent.value === 100 ? 'success' : '')
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 = ''
diagnosis.value = null
progressList.value = []
progressStep.value = 0
progressTotal.value = 9
try {
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 {
diagnosing.value = false
}
}
async function runAnalysis() {
analyzing.value = true
aiAnalysis.value = ''
aiModel.value = ''
analysisProcess.value = []
try {
const resp = await fetch(`${API}/analyze/stream`)
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 === 'start') {
aiModel.value = msg.model || ''
} else if (msg.type === 'process') {
const item = analysisProcess.value.find(process => process.step === msg.step)
if (item) {
item.status = msg.status
item.title = msg.title
item.detail = msg.detail
} else {
analysisProcess.value.push({
step: msg.step,
title: msg.title,
detail: msg.detail,
status: msg.status,
})
}
} else if (msg.type === 'chunk') {
aiAnalysis.value += msg.content
} else if (msg.type === 'error') {
aiAnalysis.value = msg.message
ElMessage.warning('AI 分析异常')
} else if (msg.type === 'done') {
ElMessage.success('AI 分析完成')
}
} catch (e) {
// ignore
}
}
}
} catch (e) {
aiAnalysis.value = 'AI 分析失败: ' + e.message
ElMessage.error('AI 分析失败: ' + e.message)
} finally {
analyzing.value = false
}
}
function openHistory() {
historyVisible.value = true
}
async function loadHistory() {
historyLoading.value = true
historyDetail.value = null
try {
const params = new URLSearchParams({ limit: '200' })
if (historyStatus.value) params.set('status', historyStatus.value)
if (historyNamespace.value) params.set('namespace', historyNamespace.value)
const resp = await fetch(`${API}/history/diagnoses?${params.toString()}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
historyItems.value = data.items || []
} catch (e) {
ElMessage.error('加载历史记录失败: ' + e.message)
} finally {
historyLoading.value = false
}
}
async function loadHistoryDetail(row) {
historyLoading.value = true
try {
const resp = await fetch(`${API}/history/diagnoses/${row.id}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
historyDetail.value = await resp.json()
} catch (e) {
ElMessage.error('加载历史详情失败: ' + e.message)
} finally {
historyLoading.value = false
}
}
function historyStatusType(status) {
return status === 'healthy' ? 'success' : status === 'warning' ? 'warning' : status === 'critical' ? 'danger' : 'info'
}
function historyStatusText(status) {
return { healthy: '健康', warning: '警告', critical: '严重' }[status] || status
}
function runKindText(kind) {
return { analysis: 'AI 分析', agent: 'Agent 自主诊断', chat: 'AI 对话' }[kind] || kind
}
function runStatusText(status) {
return { running: '运行中', completed: '已完成', waiting: '等待处理', failed: '失败', rejected: '已拒绝' }[status] || status
}
function formatHistoryEvent(event) {
const { type, created_at, ...payload } = event
return JSON.stringify(payload, null, 2)
}
async function runAgentDiagnosis() {
activeAgentMode.value = agentExecutionMode.value
agentRunning.value = true
agentEvents.value = []
try {
const params = new URLSearchParams()
params.set('execution_mode', activeAgentMode.value)
if (namespace.value) params.set('question', `重点检查命名空间 ${namespace.value}`)
const resp = await fetch(`${API}/agent/diagnose/stream?${params.toString()}`)
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`)
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
try {
const event = JSON.parse(line.slice(6))
if (!['start', 'done'].includes(event.type)) agentEvents.value.push(event)
if (event.type === 'error') ElMessage.error(event.message)
} catch (e) {
// ignore partial events
}
}
}
} catch (e) {
ElMessage.error('自主诊断失败: ' + e.message)
} finally {
agentRunning.value = false
}
}
function agentEventTitle(event) {
const titles = {
thinking: 'Agent 思考', command: '准备执行命令', result: '命令执行结果',
manual_command: '请手动执行命令',
approval_required: '发现修改命令,等待审批', command_rejected: '命令已被安全策略拒绝',
verification_required: '需要继续复检', response_rejected: 'AI 响应格式已自动纠正',
final: '自主诊断结论', error: 'Agent 执行异常',
}
return titles[event.type] || event.type
}
async function copyAgentCommand(command) {
try {
await navigator.clipboard.writeText(command)
ElMessage.success('命令已复制,请在终端中手动执行')
} catch (e) {
ElMessage.error('复制失败,请手动选择命令文本')
}
}
async function resolveApproval(event, approved) {
event.executing = true
try {
const resp = await fetch(`${API}/agent/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approval_id: event.approval_id, approved }),
})
if (!resp.ok) {
const error = await resp.json()
throw new Error(error.detail || `HTTP ${resp.status}`)
}
if (!approved) {
const result = await resp.json()
event.resolved = true
event.approvalSuccess = false
event.approvalResult = '已拒绝执行该命令'
ElMessage.info(event.approvalResult)
return
}
if (!resp.body) throw new Error('审批接口未返回流式响应')
event.resolved = true
agentRunning.value = true
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 nextEvent = JSON.parse(line.slice(6))
if (nextEvent.type === 'approved_result') {
event.approvalSuccess = nextEvent.returncode === 0
event.approvalResult = nextEvent.returncode === 0 ? '命令执行成功,Agent 继续诊断中' : `命令执行失败:${nextEvent.stderr || nextEvent.returncode}`
agentEvents.value.push({ type: 'result', ...nextEvent })
} else if (!['start', 'done'].includes(nextEvent.type)) {
agentEvents.value.push(nextEvent)
if (nextEvent.type === 'error') ElMessage.error(nextEvent.message)
}
}
}
ElMessage.success('审批命令已执行,Agent 已继续诊断')
} catch (e) {
ElMessage.error('审批处理失败: ' + e.message)
} finally {
event.executing = false
agentRunning.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()
// 添加一个空的 assistant 消息用于流式填充
const assistantIdx = chatMessages.value.length
chatMessages.value.push({ role: 'assistant', content: '' })
try {
const resp = await fetch(`${API}/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: chatMessages.value.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
}),
})
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 === 'chunk') {
chatMessages.value[assistantIdx].content += msg.content
await nextTick()
scrollChat()
} else if (msg.type === 'error') {
chatMessages.value[assistantIdx].content = msg.message
}
} catch (e) {
// ignore
}
}
}
} catch (e) {
chatMessages.value[assistantIdx].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; }
/* 诊断进度 */
.progress-box { display: flex; justify-content: center; padding-top: 40px; }
.progress-card { width: 600px; }
/* 连接失败 */
.conn-error-box { display: flex; justify-content: center; padding-top: 40px; }
.conn-error-card { width: 700px; text-align: center; padding: 20px 0; }
.conn-error-icon { margin-bottom: 16px; }
.conn-error-card h2 { font-size: 22px; color: #303133; margin-bottom: 20px; }
.conn-error-detail { text-align: left; margin: 0 20px; }
.conn-error-pre { white-space: pre-wrap; word-break: break-all; font-size: 13px; margin: 8px 0 0; color: #f56c6c; }
.conn-error-actions { margin-top: 24px; }
.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; }
.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-header-meta { display: flex; align-items: center; gap: 8px; }
.analysis-process { padding: 4px 0; }
.process-heading { display: flex; justify-content: space-between; margin-bottom: 10px; color: #606266; font-size: 13px; font-weight: 600; }
.analysis-step-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
.analysis-step {
display: flex; align-items: center; gap: 10px; min-height: 64px;
padding: 11px 12px; border: 1px solid #ebeef5; border-radius: 8px; background: #fafafa;
}
.analysis-step.status-running { border-color: #a0cfff; background: #ecf5ff; }
.analysis-step.status-done { border-color: #b3e19d; background: #f0f9eb; }
.analysis-step-icon { display: flex; align-items: center; flex-shrink: 0; }
.analysis-step-body { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 4px; }
.analysis-step-body strong { color: #303133; font-size: 14px; }
.analysis-step-body span { color: #909399; font-size: 12px; line-height: 1.4; }
.ai-loading { display: flex; align-items: center; gap: 8px; color: #409eff; padding: 6px 0 12px; }
.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; }
.agent-card { border: 1px solid #f3d19e; }
.agent-mode-switch { flex-shrink: 0; }
.agent-alert { margin-bottom: 16px; }
.agent-timeline { display: flex; flex-direction: column; gap: 12px; }
.agent-event { padding: 14px; border: 1px solid #ebeef5; border-radius: 8px; background: #fafafa; }
.agent-event.event-thinking { border-color: #a0cfff; background: #ecf5ff; }
.agent-event.event-approval_required { border-color: #eebe77; background: #fdf6ec; }
.agent-event.event-final { border-color: #b3e19d; background: #f0f9eb; }
.agent-event-title { display: flex; align-items: center; gap: 8px; color: #303133; }
.agent-event-message { margin-top: 8px; color: #606266; font-size: 13px; }
.agent-command, .agent-output {
margin-top: 10px; padding: 12px; border-radius: 6px; overflow-x: auto;
white-space: pre-wrap; word-break: break-all; font-size: 13px;
}
.agent-command { color: #d4d4d4; background: #1e1e1e; }
.agent-output { max-height: 320px; color: #303133; background: #f4f4f5; }
.approval-actions { display: flex; gap: 8px; margin-top: 12px; }
.manual-actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; color: #606266; font-size: 13px; }
.agent-running { display: flex; align-items: center; gap: 8px; color: #409eff; padding: 8px; }
.history-toolbar { display: flex; gap: 10px; margin-bottom: 16px; }
.history-detail { margin-top: 20px; }
.history-collapse { margin-top: 16px; }
.history-report { max-height: 480px; padding: 14px; overflow: auto; border-radius: 6px; background: #f4f4f5; white-space: pre-wrap; }
.history-event { margin-top: 10px; padding: 12px; border: 1px solid #ebeef5; border-radius: 6px; }
.history-event-head { display: flex; align-items: center; gap: 10px; color: #909399; font-size: 12px; }
.history-event pre { max-height: 300px; margin-top: 8px; padding: 10px; overflow: auto; background: #f4f4f5; white-space: pre-wrap; }
.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; }
@media (max-width: 900px) {
.app-header { height: auto; padding: 12px 16px; align-items: flex-start; gap: 12px; }
.header-left, .header-right { flex-wrap: wrap; }
.analysis-step-list { grid-template-columns: 1fr; }
}
</style>