diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index e138fb4..948704d 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -33,7 +33,7 @@ | HPA 列表/创建/删除 | ✅ | ✅ | ✅ | | ConfigMap/Secret 列表 | ✅ | ✅ | ✅ | | ConfigMap/Secret 编辑 | ✅ UpdateResourceYAMLNS | ✅ YamlViewer(autoEdit) | ✅ curl 验证 | -| Events 列表 | ✅ ListEvents | ✅ EventsPage | ✅ | +| Events 列表 | ✅ ListEvents + GetEventsSummary(M12) | ✅ EventsPage 聚合看板(M12) | ✅ | | 容量分析 | ✅ GetCapacityAnalysis | ✅ CapacityPage | ✅ | | kubectl 终端 | ✅ KubectlShell(WS) | ✅ KubectlTerminal | ✅ | | 拓扑图 | ✅ topology get/save | ✅ TopologyView | ✅ | diff --git a/ROADMAP.md b/ROADMAP.md index 687e762..9ec8483 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -245,9 +245,43 @@ ### 后续(P3+) +### M12: 事件聚合 + 告警联动 [done] +**目标**: 从扁平事件列表升级为事件聚合看板,支持原因/命名空间/资源类型多维聚合 + 告警联动展示 + +**后端改动**: +- `kubernetesclient/client.go`: EventItem 结构体已有完整字段(InvolvedObject/Type/Reason/Message/Count/FirstTimestamp/LastTimestamp),无需改动 +- `kubernetes.go`: 新增 `GetEventsSummary` handler — 聚合事件统计: + - 按 Reason 聚合(Top 15):统计每种 Warning 原因的累计触发次数 + - 按 Namespace 聚合(Top 15):哪个命名空间告警最多 + - 按 Resource Kind 聚合:Pod/Node/Deployment 等资源类型分布 + - 最新告警事件列表(Top 10 by lastTimestamp) + - 时间窗口过滤(hours 参数:1h/6h/24h/7d/all) +- `routes.go`: 注册 `GET /:clusterCode/events/summary` + +**前端改动**: +- `EventsPage.tsx` 完整重写(~480行): + - 概览卡片:事件总数 / 告警事件累计次数 / 正常事件 / 告警类型数 + - 三维度柱状图(recharts):告警原因 Top-N / 命名空间分布 / 资源类型分布 + - 图表交互:点击柱状图自动过滤事件列表(reason/kind 联动) + - 最新告警面板:红色高亮卡片,展示最近 10 条 Warning 事件 + - 时间窗口选择器(全部/1h/6h/24h/7d) + - 事件列表增强:可展开行(显示首次/最后出现时间 + 事件名称)、告警类型高亮、相对时间显示 + - 已选过滤 Badge(可点击移除) + +**验收标准**: +- [x] `GET /:clusterCode/events/summary` 返回 total/warning_count/normal_count/by_reason/by_namespace/by_kind/recent_warnings +- [x] 时间窗口过滤正常工作(hours=24 只返回近 24h 事件) +- [x] 前端三维度柱状图正确渲染(recharts) +- [x] 点击图表柱可过滤事件列表 +- [x] 最新告警面板展示 Warning 事件 +- [x] 事件列表行可展开查看详情 +- [x] TypeScript 零编译错误 + Vite 构建成功 +- [x] SPA 路由 `/kubernetes/:code/events` 返回 200 + +--- + | 模块 | 描述 | |------|------| -| M12 | 事件聚合 + 告警联动 | | M13 | 多集群资源对比看板 | --- diff --git a/TDD_PLAN.md b/TDD_PLAN.md index e24bb0f..391c4e2 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -387,3 +387,49 @@ cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -3 - [x] WorkloadViewPage 支持 view/yaml 双模式切换 - [x] WorkloadViewPage kind 从 useLocation 正确提取(DaemonSet 不被误判为 StatefulSet) - [x] 更新策略/选择器/PVC模板/InitContainers 全部展示 + +--- + +## M12: 事件聚合 + 告警联动 + +### 验证用例 + +```bash +# U1: 登录获取 Token +TOKEN=*** -s http://127.0.0.1:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' | jq -r '.data.token') + +# U2: Events Summary API(聚合统计) +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/events/summary" \ + -H "Authorization: Bearer $TOKEN" | jq '.data | {total, warning_count, normal_count, reasons: (.by_reason|length), namespaces: (.by_namespace|length), kinds: (.by_kind|length), recent: (.recent_warnings|length)}' +# 期望: total/warning_count/normal_count 为数字,by_reason/by_namespace/by_kind/recent_warnings 为数组 + +# U3: Events Summary 时间窗口过滤 +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/events/summary?hours=24" \ + -H "Authorization: Bearer $TOKEN" | jq '.data.total' +# 期望: 只返回近 24 小时事件计数 + +# U4: Events List 仍正常 +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/events" \ + -H "Authorization: Bearer $TOKEN" | jq '.data | length' +# 期望: 返回事件数组 + +# U5: SPA 路由 +curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/kubernetes/prod-cluster/events +# 期望: 200 + +# U6: TypeScript + Vite 构建 +cd frontend && npx tsc --noEmit && npm run build 2>&1 | tail -3 +# 期望: 零错误 + built in Ns +``` + +### 通过标准 +- [x] U2: Events Summary API 返回完整聚合数据(total/warning_count/normal_count/by_reason/by_namespace/by_kind/recent_warnings) +- [x] U3: 时间窗口过滤正常(hours=24 只统计近 24h) +- [x] U4: Events List API 不受影响 +- [x] U5: SPA 路由返回 200 +- [x] U6: TypeScript 零编译错误 + Vite 构建成功 +- [x] 前端三维度柱状图正确渲染(recharts) +- [x] 图表点击联动过滤事件列表 +- [x] 最新告警面板展示 Warning 事件 diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index c603df9..833b822 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -3015,6 +3015,155 @@ func (h *KubernetesHandler) ListEvents(c *gin.Context) { c.JSON(200, models.Response{Code: 200, Data: result}) } +// ============================================================================ +// Event Aggregation — 事件聚合统计 (M12) +// ============================================================================ + +// GetEventsSummary returns aggregated event statistics for dashboard +func (h *KubernetesHandler) GetEventsSummary(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { + c.JSON(500, models.Response{Code: 500, Message: "cluster not found"}) + return + } + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: err.Error()}) + return + } + + ns := c.Query("namespace") + events, err := client.ListEvents(ns) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}) + return + } + + // Time window filter (hours) + timeWindowHours := 0 + if tw := c.Query("hours"); tw != "" { + if v, err := strconv.Atoi(tw); err == nil && v > 0 { + timeWindowHours = v + } + } + now := time.Now() + cutoff := time.Time{} + if timeWindowHours > 0 { + cutoff = now.Add(-time.Duration(timeWindowHours) * time.Hour) + } + + // Aggregation maps + totalCount := 0 + warningCount := 0 + normalCount := 0 + byReason := map[string]int{} + byNamespace := map[string]int{} + byKind := map[string]int{} + recentWarnings := []map[string]interface{}{} + + for _, e := range events.Items { + // Time window filter + if !cutoff.IsZero() && !e.LastTimestamp.IsZero() && e.LastTimestamp.Before(cutoff) { + continue + } + + totalCount++ + count := int(e.Count) + if count == 0 { + count = 1 + } + + nsName := e.Metadata.Namespace + if nsName == "" { + nsName = e.InvolvedObject.Namespace + } + + if e.Type == "Warning" { + warningCount += count + byReason[e.Reason] += count + byNamespace[nsName] += count + byKind[e.InvolvedObject.Kind] += count + + // Collect recent warning events (top 10 by lastTimestamp) + if len(recentWarnings) < 50 { + recentWarnings = append(recentWarnings, map[string]interface{}{ + "namespace": nsName, + "type": e.Type, + "reason": e.Reason, + "message": e.Message, + "object": fmt.Sprintf("%s/%s", e.InvolvedObject.Kind, e.InvolvedObject.Name), + "count": count, + "last_time": e.LastTimestamp.Format(time.RFC3339), + "first_time": e.FirstTimestamp.Format(time.RFC3339), + }) + } + } else { + normalCount += count + } + } + + // Sort byReason by count desc → top reasons + type ReasonStat struct { + Reason string `json:"reason"` + Count int `json:"count"` + } + reasonList := make([]ReasonStat, 0, len(byReason)) + for r, cnt := range byReason { + reasonList = append(reasonList, ReasonStat{Reason: r, Count: cnt}) + } + sort.Slice(reasonList, func(i, j int) bool { return reasonList[i].Count > reasonList[j].Count }) + if len(reasonList) > 15 { + reasonList = reasonList[:15] + } + + // Sort byNamespace + type NsStat struct { + Namespace string `json:"namespace"` + Count int `json:"count"` + } + nsList := make([]NsStat, 0, len(byNamespace)) + for n, cnt := range byNamespace { + nsList = append(nsList, NsStat{Namespace: n, Count: cnt}) + } + sort.Slice(nsList, func(i, j int) bool { return nsList[i].Count > nsList[j].Count }) + if len(nsList) > 15 { + nsList = nsList[:15] + } + + // Sort byKind + type KindStat struct { + Kind string `json:"kind"` + Count int `json:"count"` + } + kindList := make([]KindStat, 0, len(byKind)) + for k, cnt := range byKind { + kindList = append(kindList, KindStat{Kind: k, Count: cnt}) + } + sort.Slice(kindList, func(i, j int) bool { return kindList[i].Count > kindList[j].Count }) + + // Sort recentWarnings by last_time desc + sort.Slice(recentWarnings, func(i, j int) bool { + ti, _ := recentWarnings[i]["last_time"].(string) + tj, _ := recentWarnings[j]["last_time"].(string) + return ti > tj + }) + if len(recentWarnings) > 10 { + recentWarnings = recentWarnings[:10] + } + + result := map[string]interface{}{ + "total": totalCount, + "warning_count": warningCount, + "normal_count": normalCount, + "by_reason": reasonList, + "by_namespace": nsList, + "by_kind": kindList, + "recent_warnings": recentWarnings, + } + + c.JSON(200, models.Response{Code: 200, Data: result}) +} + // ============================================================================ // Capacity Analysis — CPU/Memory request/limit per Deployment // ============================================================================ diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 0e0270a..5cee589 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -398,6 +398,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c kubernetesRouter.GET("/:clusterCode/metrics/pods", k8sHandler.GetPodMetrics) // 🆕 Events kubernetesRouter.GET("/:clusterCode/events", k8sHandler.ListEvents) + kubernetesRouter.GET("/:clusterCode/events/summary", k8sHandler.GetEventsSummary) // 🆕 Capacity Analysis kubernetesRouter.GET("/:clusterCode/capacity", k8sHandler.GetCapacityAnalysis) // 🆕 YAML Apply diff --git a/frontend/src/components/kubernetes/EventsPage.tsx b/frontend/src/components/kubernetes/EventsPage.tsx index 3b45ceb..50ffab4 100644 --- a/frontend/src/components/kubernetes/EventsPage.tsx +++ b/frontend/src/components/kubernetes/EventsPage.tsx @@ -9,9 +9,16 @@ import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; -import { RefreshCw, ChevronRight, Bell, AlertTriangle, Info } from 'lucide-react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + Cell, +} from 'recharts'; +import { RefreshCw, ChevronRight, Bell, AlertTriangle, Info, Activity, Server, Layers } from 'lucide-react'; import { kubernetesApi } from '@/services/api'; +// Color palette for charts +const CHART_COLORS = ['#EF4444', '#F59E0B', '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16', '#06B6D4']; + export default function EventsPage() { const { clusterCode } = useParams<{ clusterCode: string }>(); const navigate = useNavigate(); @@ -21,8 +28,17 @@ export default function EventsPage() { const [namespaces, setNamespaces] = useState([]); const [selectedNs, setSelectedNs] = useState('all'); const [filterType, setFilterType] = useState('all'); + const [timeWindow, setTimeWindow] = useState('0'); // 0 = all const [activeCluster, setActiveCluster] = useState(clusterCode || ''); + // Aggregation data + const [summary, setSummary] = useState(null); + const [selectedReason, setSelectedReason] = useState(''); + const [selectedKind, setSelectedKind] = useState(''); + + // Expanded row + const [expandedRow, setExpandedRow] = useState(null); + useEffect(() => { kubernetesApi.getClusters().then((res: any) => { const list = res?.list || []; @@ -53,93 +69,367 @@ export default function EventsPage() { } catch { setEvents([]); } finally { setLoading(false); } }, [activeCluster, selectedNs, filterType]); + const loadSummary = useCallback(async () => { + if (!activeCluster) return; + try { + const ns = selectedNs === 'all' ? undefined : selectedNs; + const hours = parseInt(timeWindow) || undefined; + const res: any = await kubernetesApi.getEventsSummary(activeCluster, ns, hours); + setSummary(res || null); + } catch { setSummary(null); } + }, [activeCluster, selectedNs, timeWindow]); + useEffect(() => { loadEvents(); }, [loadEvents]); + useEffect(() => { loadSummary(); }, [loadSummary]); + + // Filter events by selected reason/kind from aggregation click + const displayEvents = useMemo(() => { + let filtered = events; + if (selectedReason) { + filtered = filtered.filter(e => e.reason === selectedReason); + } + if (selectedKind) { + filtered = filtered.filter(e => (e.object || '').startsWith(selectedKind + '/')); + } + return filtered; + }, [events, selectedReason, selectedKind]); const warningCount = useMemo(() => events.filter(e => e.type === 'Warning').length, [events]); const normalCount = useMemo(() => events.filter(e => e.type === 'Normal').length, [events]); + // Chart data from summary + const reasonData = useMemo(() => { + const list = summary?.by_reason || []; + return list.map((r: any) => ({ name: r.reason || 'Unknown', count: r.count })); + }, [summary]); + + const nsData = useMemo(() => { + const list = summary?.by_namespace || []; + return list.map((n: any) => ({ name: n.namespace || '', count: n.count })); + }, [summary]); + + const kindData = useMemo(() => { + const list = summary?.by_kind || []; + return list.map((k: any) => ({ name: k.kind || 'Unknown', count: k.count })); + }, [summary]); + + const formatTime = (ts: string) => { + if (!ts) return '-'; + return ts.replace('T', ' ').substring(0, 19); + }; + + const formatRelative = (ts: string) => { + if (!ts) return ''; + const diff = Date.now() - new Date(ts).getTime(); + if (diff < 0) return ''; + const mins = Math.floor(diff / 60000); + if (mins < 1) return '刚刚'; + if (mins < 60) return `${mins}分钟前`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}小时前`; + return `${Math.floor(hrs / 24)}天前`; + }; + return (
+ {/* Breadcrumb */}
集群 {activeCluster && <>{activeCluster}} 事件
+ {/* Header + Filters */}
-

集群事件

-
+

+ 事件中心 +

+
- { setSelectedNs(v); setSelectedReason(''); setSelectedKind(''); }}> + 所有命名空间 {namespaces.map(n => {n})} - setTimeWindow(v)}> + - 所有类型 - ⚠ Warning - ℹ Normal + 全部时间 + 近 1 小时 + 近 6 小时 + 近 24 小时 + 近 7 天 -
-
- -

总数

{events.length}

-
- -

Warning

{warningCount}

-
- -

Normal

{normalCount}

-
+ {/* Summary Stats Cards */} +
+ + +
+
+

事件总数

+

{summary?.total ?? events.length}

+
+ +
+
+
+ + +
+
+

告警事件

+

{summary?.warning_count ?? warningCount}

+

Warning 累计触发次数

+
+ +
+
+
+ + +
+
+

正常事件

+

{summary?.normal_count ?? normalCount}

+
+ +
+
+
+ + +
+
+

告警类型数

+

{reasonData.length}

+

不同 Reason 数量

+
+ +
+
+
+ {/* Active Filter Badge */} + {(selectedReason || selectedKind) && ( +
+ 已选过滤: + {selectedReason && ( + setSelectedReason('')}> + 原因: {selectedReason} ✕ + + )} + {selectedKind && ( + setSelectedKind('')}> + 类型: {selectedKind} ✕ + + )} +
+ )} + + {/* Charts Row */} + {reasonData.length > 0 && ( +
+ {/* By Reason Chart */} + + +

+ 告警原因 Top {reasonData.length} +

+ + + + + + + setSelectedReason(prev => prev === d?.name ? '' : d?.name)}> + {reasonData.map((_: any, i: number) => ( + + ))} + + + +

点击柱状图过滤事件列表

+
+
+ + {/* By Namespace Chart */} + {nsData.length > 0 && ( + + +

+ 命名空间分布 Top {nsData.length} +

+ + + + + + + + + +
+
+ )} + + {/* By Kind Chart */} + {kindData.length > 0 && ( + + +

+ 资源类型分布 +

+ + + + + + + setSelectedKind(prev => prev === d?.name ? '' : d?.name)}> + {kindData.map((_: any, i: number) => ( + + ))} + + + +

点击柱状图过滤事件列表

+
+
+ )} +
+ )} + + {/* Recent Warnings Alert Panel */} + {summary?.recent_warnings?.length > 0 && ( + + +

+ 最新告警事件 ({summary.recent_warnings.length}) +

+
+ {summary.recent_warnings.map((w: any, i: number) => ( +
+ +
+
+ {w.reason} + {w.object} + {w.namespace} + ×{w.count} + {formatRelative(w.last_time)} +
+

{w.message}

+
+
+ ))} +
+
+
+ )} + + {/* Events Table */} +
+ 事件列表 ({displayEvents.length}) + +
类型 - 原因 + 原因 对象 消息 次数 - 最后时间 + 命名空间 + 最后时间 - {events.length === 0 ? ( - 暂无事件{loading ? ⏳ : ''} - ) : events.map((e, i) => ( - - - {e.type === 'Warning' ? ( - Warning - ) : ( - Normal - )} - - {e.reason || '-'} - {e.object || '-'} - {e.message || '-'} - {e.count || 1} - {e.last_time?.replace('T', ' ').substring(0, 19) || '-'} - - ))} + {displayEvents.length === 0 ? ( + + {loading ? ⏳ : '暂无事件'} + + ) : displayEvents.slice(0, 200).map((e, i) => { + const rowKey = `${e.name}-${i}`; + const isExpanded = expandedRow === rowKey; + return ( + setExpandedRow(isExpanded ? null : rowKey)} + > + + {e.type === 'Warning' ? ( + W + ) : ( + N + )} + + + {e.reason || '-'} + + {e.object || '-'} + +
+ {e.message || '-'} +
+ {isExpanded && ( +
+
首次出现: {formatTime(e.first_time)} ({formatRelative(e.first_time)})
+
最后出现: {formatTime(e.last_time)} ({formatRelative(e.last_time)})
+
事件名称: {e.name}
+
+ )} +
+ + {e.count > 1 ? {e.count} : 1} + + {e.namespace || '-'} + +
{formatTime(e.last_time)}
+
{formatRelative(e.last_time)}
+
+
+ ); + })}
+ {displayEvents.length > 200 && ( +
+ 仅显示前 200 条,共 {displayEvents.length} 条事件 +
+ )}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a42d2d3..ed6f7a8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -484,6 +484,12 @@ export const kubernetesApi = { if (type) params.type = type; return http.get(`/kubernetes/${clusterCode}/events`, params); }, + getEventsSummary: async (clusterCode: string, namespace?: string, hours?: number) => { + const params: any = {}; + if (namespace) params.namespace = namespace; + if (hours) params.hours = hours; + return http.get(`/kubernetes/${clusterCode}/events/summary`, params); + }, // === 🆕 Capacity Analysis === getCapacity: async (clusterCode: string, namespace?: string) => {