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
+24
View File
@@ -0,0 +1,24 @@
# Dependencies
node_modules/
frontend/node_modules/
# Build output
frontend/dist/
# Python
__pycache__/
*.pyc
*.pyo
venv/
.venv/
# Environment
.env
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
+83
View File
@@ -0,0 +1,83 @@
# K8S 智能诊断平台
一键诊断 Kubernetes 集群健康状态,接入 AI Agent 自动分析故障根因并给出修复建议。
![界面预览](/home/cnbugs/.hermes/cache/screenshots/browser_screenshot_b258ca152c1e489fa186583946ecbd9e.png)
## 功能特性
- **一键诊断**:9 大检查项全面扫描集群状态
- 集群信息、Pod 状态、Node 状态、异常事件
- Deployment 副本检查、Service/Endpoint 检查
- PVC 存储检查、节点资源使用率、网络策略
- **健康评分**:100 分制量化集群健康度(严重问题 -15 分,警告 -5 分)
- **AI 智能分析**:接入 OpenAI 兼容接口,自动分析诊断报告
- 根因定位、影响评估、修复命令、优先级排序、预防建议
- **AI 追问对话**:基于诊断上下文多轮追问,深入排查问题
- **Web 仪表盘**Vue3 + Element Plus,实时展示诊断结果
## 快速开始
```bash
# 1. 确保 kubectl 已配置
kubectl cluster-info
# 2. 启动服务
./start.sh
# 3. 打开浏览器
# http://localhost:8900
```
## 配置
编辑 `.env` 文件:
```env
# AI 分析接口 (OpenAI 兼容格式)
AI_API_BASE=http://localhost:8648/v1
AI_API_KEY=your-api-key
AI_MODEL=qwen3.8-max-preview
# 服务端口
PORT=8900
# Kubeconfig 路径 (留空使用默认 ~/.kube/config)
KUBECONFIG_PATH=
```
## API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/health` | 健康检查 |
| GET | `/api/checks` | 列出所有诊断项 |
| POST | `/api/diagnose` | 执行诊断 `{"namespace":"", "checks":[]}` |
| GET | `/api/diagnosis/latest` | 获取最近诊断结果 |
| POST | `/api/analyze` | AI 分析诊断结果 `{"question":""}` |
| POST | `/api/chat` | AI 多轮对话 `{"messages":[...]}` |
| GET | `/api/report` | 获取诊断报告文本 |
## 技术栈
- **后端**Python 3 + FastAPI + kubernetes-client (kubectl)
- **前端**Vue 3 + Element Plus + Vite + marked
- **AI**OpenAI 兼容 Chat Completions API
## 项目结构
```
k8smanager-cli/
├── backend/
│ ├── main.py # FastAPI 主应用
│ ├── config.py # 配置管理
│ ├── diagnosis.py # K8S 诊断引擎 (9 大检查项)
│ └── ai_agent.py # AI Agent 分析模块
├── frontend/
│ ├── src/App.vue # 仪表盘界面
│ ├── index.html
│ └── vite.config.js
├── .env # 配置文件
├── start.sh # 一键启动脚本
└── README.md
```
View File
+80
View File
@@ -0,0 +1,80 @@
"""AI Agent 分析模块 - 调用 OpenAI 兼容接口分析诊断结果"""
import httpx
from backend.config import AI_API_BASE, AI_API_KEY, AI_MODEL
SYSTEM_PROMPT = """你是一位资深的 Kubernetes 运维专家和 SRE 工程师。你的任务是分析 K8S 集群诊断报告,找出根本原因并给出修复建议。
分析要求:
1. **问题定位**:明确指出每个问题的根本原因(Root Cause),不要只描述表面现象
2. **影响评估**:说明每个问题对业务的潜在影响
3. **修复方案**:给出具体的修复命令或操作步骤(kubectl 命令、YAML 修改等)
4. **优先级排序**:按紧急程度排序,先处理影响最大的问题
5. **预防建议**:给出避免类似问题再次发生的建议
输出格式:使用清晰的 Markdown,包含标题、列表、代码块。语言使用中文。"""
async def analyze_with_ai(report: str, question: str = "") -> dict:
"""调用 AI 分析诊断报告"""
user_msg = f"以下是 K8S 集群诊断报告,请进行详细分析:\n\n{report}"
if question:
user_msg += f"\n\n用户额外问题:{question}"
try:
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
f"{AI_API_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {AI_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": AI_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
"temperature": 0.3,
"max_tokens": 4096,
},
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
return {"success": True, "analysis": content, "model": AI_MODEL}
except httpx.HTTPStatusError as e:
return {"success": False, "analysis": f"AI 接口返回错误: {e.response.status_code} - {e.response.text[:500]}", "model": AI_MODEL}
except httpx.ConnectError:
return {"success": False, "analysis": f"无法连接 AI 服务 ({AI_API_BASE}),请检查配置", "model": AI_MODEL}
except Exception as e:
return {"success": False, "analysis": f"AI 分析失败: {str(e)}", "model": AI_MODEL}
async def chat_with_ai(messages: list[dict], context: str = "") -> dict:
"""多轮对话 - 基于诊断上下文追问"""
msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
if context:
msgs.append({"role": "system", "content": f"当前诊断上下文:\n{context}"})
msgs.extend(messages)
try:
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
f"{AI_API_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {AI_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": AI_MODEL,
"messages": msgs,
"temperature": 0.3,
"max_tokens": 4096,
},
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
return {"success": True, "reply": content}
except Exception as e:
return {"success": False, "reply": f"AI 对话失败: {str(e)}"}
+11
View File
@@ -0,0 +1,11 @@
"""配置管理"""
import os
from dotenv import load_dotenv
load_dotenv()
AI_API_BASE = os.getenv("AI_API_BASE", "http://localhost:8648/v1")
AI_API_KEY = os.getenv("AI_API_KEY", "hermes")
AI_MODEL = os.getenv("AI_MODEL", "qwen3.8-max-preview")
PORT = int(os.getenv("PORT", "8900"))
KUBECONFIG_PATH = os.getenv("KUBECONFIG_PATH", "") or None
+523
View File
@@ -0,0 +1,523 @@
"""K8S 集群诊断引擎 - 通过 kubectl 命令采集集群状态"""
import asyncio
import json
import shutil
from datetime import datetime
from typing import Optional
KUBECTL = shutil.which("kubectl") or "/home/cnbugs/bin/kubectl"
async def _run_kubectl(args: list[str], timeout: int = 30) -> dict:
"""执行 kubectl 命令,返回结构化结果"""
cmd = [KUBECTL] + args + ["-o", "json"]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
if proc.returncode != 0:
return {"error": stderr.decode().strip(), "data": None}
return {"error": None, "data": json.loads(stdout.decode())}
except asyncio.TimeoutError:
return {"error": f"kubectl 命令超时 ({timeout}s)", "data": None}
except Exception as e:
return {"error": str(e), "data": None}
async def _run_kubectl_raw(args: list[str], timeout: int = 30) -> str:
"""执行 kubectl 命令,返回原始文本"""
cmd = [KUBECTL] + args
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return stdout.decode().strip()
except Exception:
return ""
def _parse_resource_quantity(q: str) -> float:
"""解析 K8S 资源量 (如 500m, 2Gi) 为数值"""
if not q:
return 0
q = str(q)
units = {"n": 1e-9, "u": 1e-6, "m": 1e-3, "": 1, "k": 1e3, "M": 1e6, "G": 1e9, "T": 1e12,
"Ki": 1024, "Mi": 1024**2, "Gi": 1024**3, "Ti": 1024**4}
for suffix, mult in sorted(units.items(), key=lambda x: -len(x[0])):
if suffix and q.endswith(suffix):
try:
return float(q[:-len(suffix)]) * mult
except ValueError:
return 0
try:
return float(q)
except ValueError:
return 0
# ─── 诊断项 ───────────────────────────────────────────────
async def check_cluster_info() -> dict:
"""集群基本信息"""
ver = await _run_kubectl(["version"])
nodes = await _run_kubectl(["get", "nodes"])
namespaces = await _run_kubectl(["get", "namespaces"])
return {
"name": "cluster_info",
"title": "集群信息",
"version": ver,
"nodes": nodes,
"namespaces": namespaces,
}
async def check_pods(namespace: str = "") -> dict:
"""Pod 状态检查"""
args = ["get", "pods"]
if namespace:
args += ["-n", namespace]
else:
args += ["--all-namespaces"]
result = await _run_kubectl(args)
issues = []
pods_summary = {"total": 0, "running": 0, "pending": 0, "failed": 0, "unknown": 0, "crashloop": 0}
if result["data"] and "items" in result["data"]:
for pod in result["data"]["items"]:
pods_summary["total"] += 1
phase = pod.get("status", {}).get("phase", "Unknown")
name = pod["metadata"]["name"]
ns = pod["metadata"].get("namespace", "default")
if phase == "Running":
pods_summary["running"] += 1
# 检查重启次数
for cs in pod.get("status", {}).get("containerStatuses", []):
restarts = cs.get("restartCount", 0)
if restarts > 5:
issues.append({
"level": "warning",
"resource": f"{ns}/{name}",
"message": f"容器 {cs['name']} 重启 {restarts}",
})
state = cs.get("state", {})
if "waiting" in state:
reason = state["waiting"].get("reason", "")
if reason in ("CrashLoopBackOff", "ErrImagePull", "ImagePullBackOff", "CreateContainerConfigError"):
pods_summary["crashloop"] += 1
issues.append({
"level": "critical",
"resource": f"{ns}/{name}",
"message": f"容器 {cs['name']} 状态异常: {reason}",
})
elif phase == "Pending":
pods_summary["pending"] += 1
issues.append({"level": "warning", "resource": f"{ns}/{name}", "message": "Pod 处于 Pending 状态"})
elif phase == "Failed":
pods_summary["failed"] += 1
issues.append({"level": "critical", "resource": f"{ns}/{name}", "message": "Pod 处于 Failed 状态"})
else:
pods_summary["unknown"] += 1
return {
"name": "pods",
"title": "Pod 状态检查",
"summary": pods_summary,
"issues": issues,
"raw": result,
}
async def check_nodes() -> dict:
"""Node 状态检查"""
result = await _run_kubectl(["get", "nodes"])
issues = []
nodes_summary = {"total": 0, "ready": 0, "not_ready": 0}
if result["data"] and "items" in result["data"]:
for node in result["data"]["items"]:
nodes_summary["total"] += 1
name = node["metadata"]["name"]
conditions = node.get("status", {}).get("conditions", [])
is_ready = False
for cond in conditions:
if cond["type"] == "Ready" and cond["status"] == "True":
is_ready = True
if cond["status"] == "True" and cond["type"] in ("MemoryPressure", "DiskPressure", "PIDPressure"):
issues.append({
"level": "critical",
"resource": name,
"message": f"节点存在 {cond['type']} 压力",
})
if is_ready:
nodes_summary["ready"] += 1
else:
nodes_summary["not_ready"] += 1
issues.append({"level": "critical", "resource": name, "message": "节点 NotReady"})
# 资源使用
alloc = node.get("status", {}).get("allocatable", {})
cap = node.get("status", {}).get("capacity", {})
if alloc and cap:
for res in ("cpu", "memory"):
a = _parse_resource_quantity(alloc.get(res, "0"))
c = _parse_resource_quantity(cap.get(res, "0"))
if c > 0 and a / c < 0.1:
issues.append({
"level": "warning",
"resource": name,
"message": f"节点 {res} 可分配量不足 10%",
})
return {
"name": "nodes",
"title": "Node 状态检查",
"summary": nodes_summary,
"issues": issues,
"raw": result,
}
async def check_events(namespace: str = "") -> dict:
"""异常事件检查"""
args = ["get", "events", "--field-selector", "type!=Normal", "--sort-by=.lastTimestamp"]
if namespace:
args += ["-n", namespace]
else:
args += ["--all-namespaces"]
result = await _run_kubectl(args)
issues = []
events_list = []
if result["data"] and "items" in result["data"]:
for ev in result["data"]["items"][-50:]: # 最近50条
events_list.append({
"namespace": ev["metadata"].get("namespace", ""),
"reason": ev.get("reason", ""),
"message": ev.get("message", ""),
"type": ev.get("type", ""),
"object": f"{ev.get('involvedObject', {}).get('kind', '')}/{ev.get('involvedObject', {}).get('name', '')}",
"lastSeen": ev.get("lastTimestamp", ""),
"count": ev.get("count", 1),
})
if ev.get("type") == "Warning":
issues.append({
"level": "warning",
"resource": f"{ev['metadata'].get('namespace', '')}/{ev.get('involvedObject', {}).get('name', '')}",
"message": f"[{ev.get('reason', '')}] {ev.get('message', '')[:200]}",
})
return {
"name": "events",
"title": "异常事件检查",
"warning_count": len(issues),
"events": events_list,
"issues": issues,
"raw": result,
}
async def check_deployments(namespace: str = "") -> dict:
"""Deployment 状态检查"""
args = ["get", "deployments"]
if namespace:
args += ["-n", namespace]
else:
args += ["--all-namespaces"]
result = await _run_kubectl(args)
issues = []
deploys = []
if result["data"] and "items" in result["data"]:
for dep in result["data"]["items"]:
name = dep["metadata"]["name"]
ns = dep["metadata"].get("namespace", "default")
spec_replicas = dep.get("spec", {}).get("replicas", 0)
status = dep.get("status", {})
ready = status.get("readyReplicas", 0) or 0
available = status.get("availableReplicas", 0) or 0
updated = status.get("updatedReplicas", 0) or 0
deploys.append({
"namespace": ns, "name": name,
"desired": spec_replicas, "ready": ready,
"available": available, "updated": updated,
})
if ready < spec_replicas:
issues.append({
"level": "critical" if ready == 0 else "warning",
"resource": f"{ns}/{name}",
"message": f"副本不足: 期望 {spec_replicas}, 就绪 {ready}",
})
if updated < spec_replicas:
issues.append({
"level": "warning",
"resource": f"{ns}/{name}",
"message": f"滚动更新未完成: 已更新 {updated}/{spec_replicas}",
})
return {
"name": "deployments",
"title": "Deployment 状态检查",
"deployments": deploys,
"issues": issues,
"raw": result,
}
async def check_services(namespace: str = "") -> dict:
"""Service / Endpoint 检查"""
args = ["get", "endpoints"]
if namespace:
args += ["-n", namespace]
else:
args += ["--all-namespaces"]
result = await _run_kubectl(args)
issues = []
if result["data"] and "items" in result["data"]:
for ep in result["data"]["items"]:
name = ep["metadata"]["name"]
ns = ep["metadata"].get("namespace", "default")
subsets = ep.get("subsets", [])
has_addr = any(s.get("addresses") for s in subsets)
if not has_addr and name != "kubernetes":
issues.append({
"level": "warning",
"resource": f"{ns}/{name}",
"message": "Service 没有可用的后端 Endpoint",
})
return {
"name": "services",
"title": "Service/Endpoint 检查",
"issues": issues,
"raw": result,
}
async def check_pvc(namespace: str = "") -> dict:
"""PVC 状态检查"""
args = ["get", "pvc"]
if namespace:
args += ["-n", namespace]
else:
args += ["--all-namespaces"]
result = await _run_kubectl(args)
issues = []
if result["data"] and "items" in result["data"]:
for pvc in result["data"]["items"]:
name = pvc["metadata"]["name"]
ns = pvc["metadata"].get("namespace", "default")
phase = pvc.get("status", {}).get("phase", "")
if phase != "Bound":
issues.append({
"level": "critical",
"resource": f"{ns}/{name}",
"message": f"PVC 状态异常: {phase}",
})
return {
"name": "pvc",
"title": "PVC 存储检查",
"issues": issues,
"raw": result,
}
async def check_resource_usage() -> dict:
"""节点资源使用率 (需要 metrics-server)"""
raw = await _run_kubectl_raw(["top", "nodes", "--no-headers"])
issues = []
nodes_usage = []
if raw and "error" not in raw.lower():
for line in raw.strip().split("\n"):
parts = line.split()
if len(parts) >= 5:
name = parts[0]
cpu_pct = parts[1].rstrip("%")
mem_pct = parts[3].rstrip("%")
try:
cpu_v, mem_v = float(cpu_pct), float(mem_pct)
nodes_usage.append({"name": name, "cpu_pct": cpu_v, "mem_pct": mem_v})
if cpu_v > 90:
issues.append({"level": "critical", "resource": name, "message": f"CPU 使用率 {cpu_v}%"})
elif cpu_v > 75:
issues.append({"level": "warning", "resource": name, "message": f"CPU 使用率 {cpu_v}%"})
if mem_v > 90:
issues.append({"level": "critical", "resource": name, "message": f"内存使用率 {mem_v}%"})
elif mem_v > 75:
issues.append({"level": "warning", "resource": name, "message": f"内存使用率 {mem_v}%"})
except ValueError:
pass
else:
return {
"name": "resource_usage",
"title": "资源使用率",
"issues": [],
"nodes": [],
"note": "metrics-server 不可用,跳过资源使用率检查",
"raw": {"error": raw},
}
return {
"name": "resource_usage",
"title": "资源使用率",
"issues": issues,
"nodes": nodes_usage,
"raw": {"text": raw},
}
async def check_network_policies(namespace: str = "") -> dict:
"""网络策略检查"""
args = ["get", "networkpolicies"]
if namespace:
args += ["-n", namespace]
else:
args += ["--all-namespaces"]
result = await _run_kubectl(args)
count = len(result["data"].get("items", [])) if result["data"] else 0
return {
"name": "network",
"title": "网络策略检查",
"policy_count": count,
"issues": [],
"raw": result,
}
# ─── 主诊断入口 ───────────────────────────────────────────
ALL_CHECKS = {
"cluster_info": check_cluster_info,
"pods": check_pods,
"nodes": check_nodes,
"events": check_events,
"deployments": check_deployments,
"services": check_services,
"pvc": check_pvc,
"resource_usage": check_resource_usage,
"network": check_network_policies,
}
async def run_full_diagnosis(namespace: str = "", checks: Optional[list[str]] = None) -> dict:
"""执行完整诊断"""
start = datetime.now()
selected = checks or list(ALL_CHECKS.keys())
tasks = {}
for name in selected:
fn = ALL_CHECKS.get(name)
if fn:
if name in ("pods", "events", "deployments", "services", "pvc", "network"):
tasks[name] = fn(namespace)
else:
tasks[name] = fn()
results = {}
for name, coro in tasks.items():
try:
results[name] = await coro
except Exception as e:
results[name] = {"name": name, "title": name, "error": str(e), "issues": []}
# 汇总
all_issues = []
for r in results.values():
for issue in r.get("issues", []):
issue["check"] = r.get("title", r.get("name", ""))
all_issues.append(issue)
critical = [i for i in all_issues if i["level"] == "critical"]
warning = [i for i in all_issues if i["level"] == "warning"]
elapsed = (datetime.now() - start).total_seconds()
# 健康评分 (100 分制)
score = 100
score -= len(critical) * 15
score -= len(warning) * 5
score = max(0, score)
if score >= 90:
status = "healthy"
elif score >= 60:
status = "warning"
else:
status = "critical"
return {
"timestamp": start.isoformat(),
"elapsed_seconds": round(elapsed, 2),
"namespace": namespace or "all",
"score": score,
"status": status,
"critical_count": len(critical),
"warning_count": len(warning),
"issues": all_issues,
"checks": results,
}
def build_diagnosis_report(diagnosis: dict) -> str:
"""将诊断结果构建为 AI 可读的文本报告"""
lines = []
lines.append(f"# K8S 集群诊断报告")
lines.append(f"时间: {diagnosis['timestamp']}")
lines.append(f"命名空间: {diagnosis['namespace']}")
lines.append(f"健康评分: {diagnosis['score']}/100 ({diagnosis['status']})")
lines.append(f"严重问题: {diagnosis['critical_count']} 个, 警告: {diagnosis['warning_count']}")
lines.append("")
if diagnosis["issues"]:
lines.append("## 发现的问题")
for i, issue in enumerate(diagnosis["issues"], 1):
icon = "🔴" if issue["level"] == "critical" else "🟡"
lines.append(f"{i}. {icon} [{issue['check']}] {issue['resource']}: {issue['message']}")
lines.append("")
# Pod 摘要
pods = diagnosis["checks"].get("pods", {})
if pods.get("summary"):
s = pods["summary"]
lines.append(f"## Pod 状态: 总计 {s['total']}, Running {s['running']}, Pending {s['pending']}, Failed {s['failed']}, CrashLoop {s.get('crashloop', 0)}")
# Node 摘要
nodes = diagnosis["checks"].get("nodes", {})
if nodes.get("summary"):
s = nodes["summary"]
lines.append(f"## Node 状态: 总计 {s['total']}, Ready {s['ready']}, NotReady {s['not_ready']}")
# Deployment 摘要
deps = diagnosis["checks"].get("deployments", {})
if deps.get("deployments"):
lines.append("## Deployment 状态:")
for d in deps["deployments"]:
lines.append(f" - {d['namespace']}/{d['name']}: {d['ready']}/{d['desired']} ready")
# 异常事件
events = diagnosis["checks"].get("events", {})
if events.get("events"):
lines.append(f"\n## 最近异常事件 (共 {len(events['events'])} 条):")
for ev in events["events"][-20:]:
lines.append(f" - [{ev['type']}] {ev['object']}: {ev['reason']} - {ev['message'][:150]}")
# 资源使用
usage = diagnosis["checks"].get("resource_usage", {})
if usage.get("nodes"):
lines.append("\n## 节点资源使用:")
for n in usage["nodes"]:
lines.append(f" - {n['name']}: CPU {n['cpu_pct']}%, 内存 {n['mem_pct']}%")
return "\n".join(lines)
+108
View File
@@ -0,0 +1,108 @@
"""K8S 智能诊断平台 - FastAPI 主应用"""
import os
from datetime import datetime
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import Optional
from backend.config import PORT
from backend.diagnosis import run_full_diagnosis, build_diagnosis_report, ALL_CHECKS
from backend.ai_agent import analyze_with_ai, chat_with_ai
app = FastAPI(title="K8S 智能诊断平台", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# 存储最近诊断结果 (内存)
_last_diagnosis: Optional[dict] = None
_last_report: str = ""
class DiagnoseRequest(BaseModel):
namespace: str = ""
checks: Optional[list[str]] = None
class AnalyzeRequest(BaseModel):
question: str = ""
class ChatRequest(BaseModel):
messages: list[dict]
@app.get("/api/health")
async def health():
return {"status": "ok", "time": datetime.now().isoformat()}
@app.get("/api/checks")
async def list_checks():
"""列出所有可用诊断项"""
return {
"checks": [
{"name": name, "title": fn.__doc__ or name}
for name, fn in ALL_CHECKS.items()
]
}
@app.post("/api/diagnose")
async def diagnose(req: DiagnoseRequest):
"""执行一键诊断"""
global _last_diagnosis, _last_report
_last_diagnosis = await run_full_diagnosis(namespace=req.namespace, checks=req.checks)
_last_report = build_diagnosis_report(_last_diagnosis)
return _last_diagnosis
@app.get("/api/diagnosis/latest")
async def get_latest():
"""获取最近一次诊断结果"""
if _last_diagnosis is None:
raise HTTPException(status_code=404, detail="暂无诊断记录,请先执行诊断")
return _last_diagnosis
@app.post("/api/analyze")
async def analyze(req: AnalyzeRequest):
"""AI 分析诊断结果"""
global _last_diagnosis, _last_report
if _last_diagnosis is None:
_last_diagnosis = await run_full_diagnosis()
_last_report = build_diagnosis_report(_last_diagnosis)
result = await analyze_with_ai(_last_report, question=req.question)
return result
@app.post("/api/chat")
async def chat(req: ChatRequest):
"""AI 多轮对话 (基于诊断上下文追问)"""
result = await chat_with_ai(req.messages, context=_last_report)
return result
@app.get("/api/report")
async def get_report():
"""获取诊断报告文本"""
if not _last_report:
raise HTTPException(status_code=404, detail="暂无诊断报告")
return {"report": _last_report}
# 静态文件服务 (前端)
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "dist")
if os.path.isdir(static_dir):
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=PORT)
+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',
},
})
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# K8S 智能诊断平台 - 一键启动脚本
set -e
cd "$(dirname "$0")"
PORT=${PORT:-8900}
echo "========================================="
echo " K8S 智能诊断平台"
echo " 端口: $PORT"
echo "========================================="
# 检查 kubectl
KUBECTL=$(which kubectl 2>/dev/null || echo "$HOME/bin/kubectl")
if [ ! -x "$KUBECTL" ]; then
echo "⚠️ 未找到 kubectl,请先安装"
exit 1
fi
echo "✅ kubectl: $KUBECTL"
# 检查 kubeconfig
if [ -f "$HOME/.kube/config" ]; then
echo "✅ kubeconfig: ~/.kube/config"
else
echo "⚠️ 未找到 ~/.kube/config,诊断将返回空结果"
echo " 请将 kubeconfig 放到 ~/.kube/config"
fi
# 检查前端构建
if [ ! -d "frontend/dist" ]; then
echo "📦 构建前端..."
cd frontend && npx vite build && cd ..
fi
echo ""
echo "🚀 启动服务: http://localhost:$PORT"
echo ""
PORT=$PORT python3 -m uvicorn backend.main:app --host 0.0.0.0 --port "$PORT"