feat(k8s): M12 事件聚合 + 告警联动 — EventsPage 聚合看板
- 后端: 新增 GetEventsSummary handler — 按 Reason/Namespace/Kind 三维度聚合 + 时间窗口过滤 - 后端: 注册 GET /:clusterCode/events/summary 路由 - 前端: EventsPage 完整重写(~480行) — 概览卡片 + 三维度柱状图(recharts) + 图表联动过滤 + 最新告警面板 + 可展开事件行 + 时间窗口选择器 - 前端: api.ts 新增 getEventsSummary 方法 - 验证: API 返回完整聚合数据(total=3, warning_count=613, BackOff top reason), SPA 200, TS零错误, Vite构建成功
This commit is contained in:
1 parent
e76061661b
commit
7192f58b72
7 files changed
+569
-43
No files matched your search
@@ -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 | ✅ |
|
||||
|
||||
+35
-1
@@ -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 | 多集群资源对比看板 |
|
||||
|
||||
---
|
||||
|
||||
+46
@@ -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 事件
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
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<any>(null);
|
||||
const [selectedReason, setSelectedReason] = useState<string>('');
|
||||
const [selectedKind, setSelectedKind] = useState<string>('');
|
||||
|
||||
// Expanded row
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(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 || '<cluster>', 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 (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Link to="/kubernetes" className="hover:text-slate-300">集群</Link>
|
||||
{activeCluster && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${activeCluster}`} className="hover:text-slate-300">{activeCluster}</Link></>}
|
||||
<ChevronRight className="h-3 w-3" /><span className="text-slate-300">事件</span>
|
||||
</div>
|
||||
|
||||
{/* Header + Filters */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2"><Bell className="h-5 w-5 text-yellow-400" />集群事件</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-yellow-400" />事件中心
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/events`); }}>
|
||||
<SelectTrigger className="w-40 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{clusters.map((c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedNs} onValueChange={setSelectedNs}>
|
||||
<SelectTrigger className="w-32 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue placeholder="所有命名空间" /></SelectTrigger>
|
||||
<Select value={selectedNs} onValueChange={v => { setSelectedNs(v); setSelectedReason(''); setSelectedKind(''); }}>
|
||||
<SelectTrigger className="w-36 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue placeholder="命名空间" /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all" className="text-xs">所有命名空间</SelectItem>
|
||||
{namespaces.map(n => <SelectItem key={n} value={n} className="text-xs">{n}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="w-28 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue placeholder="所有类型" /></SelectTrigger>
|
||||
<Select value={timeWindow} onValueChange={v => setTimeWindow(v)}>
|
||||
<SelectTrigger className="w-32 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all" className="text-xs">所有类型</SelectItem>
|
||||
<SelectItem value="Warning" className="text-xs">⚠ Warning</SelectItem>
|
||||
<SelectItem value="Normal" className="text-xs">ℹ Normal</SelectItem>
|
||||
<SelectItem value="0" className="text-xs">全部时间</SelectItem>
|
||||
<SelectItem value="1" className="text-xs">近 1 小时</SelectItem>
|
||||
<SelectItem value="6" className="text-xs">近 6 小时</SelectItem>
|
||||
<SelectItem value="24" className="text-xs">近 24 小时</SelectItem>
|
||||
<SelectItem value="168" className="text-xs">近 7 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={loadEvents} disabled={loading}>
|
||||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={() => { loadEvents(); loadSummary(); }} disabled={loading}>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Card className="bg-slate-900 border-slate-800"><CardContent className="p-3 text-center">
|
||||
<p className="text-xs text-slate-500">总数</p><p className="text-xl font-bold text-slate-200">{events.length}</p>
|
||||
</CardContent></Card>
|
||||
<Card className="bg-slate-900 border-red-900/30"><CardContent className="p-3 text-center">
|
||||
<p className="text-xs text-slate-500">Warning</p><p className="text-xl font-bold text-red-400">{warningCount}</p>
|
||||
</CardContent></Card>
|
||||
<Card className="bg-slate-900 border-emerald-900/30"><CardContent className="p-3 text-center">
|
||||
<p className="text-xs text-slate-500">Normal</p><p className="text-xl font-bold text-emerald-400">{normalCount}</p>
|
||||
</CardContent></Card>
|
||||
{/* Summary Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">事件总数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{summary?.total ?? events.length}</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-slate-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-red-900/40">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">告警事件</p>
|
||||
<p className="text-2xl font-bold text-red-400">{summary?.warning_count ?? warningCount}</p>
|
||||
<p className="text-[10px] text-slate-600 mt-0.5">Warning 累计触发次数</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-red-500/30" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-emerald-900/40">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">正常事件</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">{summary?.normal_count ?? normalCount}</p>
|
||||
</div>
|
||||
<Info className="h-8 w-8 text-emerald-500/30" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">告警类型数</p>
|
||||
<p className="text-2xl font-bold text-amber-400">{reasonData.length}</p>
|
||||
<p className="text-[10px] text-slate-600 mt-0.5">不同 Reason 数量</p>
|
||||
</div>
|
||||
<Layers className="h-8 w-8 text-amber-500/30" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Active Filter Badge */}
|
||||
{(selectedReason || selectedKind) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">已选过滤:</span>
|
||||
{selectedReason && (
|
||||
<Badge className="bg-amber-500/20 text-amber-300 border-amber-500/30 cursor-pointer text-xs" onClick={() => setSelectedReason('')}>
|
||||
原因: {selectedReason} ✕
|
||||
</Badge>
|
||||
)}
|
||||
{selectedKind && (
|
||||
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 cursor-pointer text-xs" onClick={() => setSelectedKind('')}>
|
||||
类型: {selectedKind} ✕
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts Row */}
|
||||
{reasonData.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* By Reason Chart */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-400" />告警原因 Top {reasonData.length}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={reasonData} layout="vertical" margin={{ left: 10, right: 10, top: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 10 }} axisLine={{ stroke: '#334155' }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: '#94a3b8', fontSize: 10 }} width={100} axisLine={{ stroke: '#334155' }} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', fontSize: '12px' }}
|
||||
labelStyle={{ color: '#e2e8f0' }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} cursor="pointer" onClick={(d: any) => setSelectedReason(prev => prev === d?.name ? '' : d?.name)}>
|
||||
{reasonData.map((_: any, i: number) => (
|
||||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="text-[10px] text-slate-600 text-center mt-1">点击柱状图过滤事件列表</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* By Namespace Chart */}
|
||||
{nsData.length > 0 && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-1.5">
|
||||
<Server className="h-4 w-4 text-blue-400" />命名空间分布 Top {nsData.length}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={nsData} layout="vertical" margin={{ left: 10, right: 10, top: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 10 }} axisLine={{ stroke: '#334155' }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: '#94a3b8', fontSize: 10 }} width={80} axisLine={{ stroke: '#334155' }} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', fontSize: '12px' }}
|
||||
labelStyle={{ color: '#e2e8f0' }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill="#3B82F6" fillOpacity={0.7} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* By Kind Chart */}
|
||||
{kindData.length > 0 && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-1.5">
|
||||
<Layers className="h-4 w-4 text-purple-400" />资源类型分布
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={kindData} layout="vertical" margin={{ left: 10, right: 10, top: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 10 }} axisLine={{ stroke: '#334155' }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: '#94a3b8', fontSize: 10 }} width={80} axisLine={{ stroke: '#334155' }} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1e293b', border: '1px solid #334155', borderRadius: '6px', fontSize: '12px' }}
|
||||
labelStyle={{ color: '#e2e8f0' }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} cursor="pointer" onClick={(d: any) => setSelectedKind(prev => prev === d?.name ? '' : d?.name)}>
|
||||
{kindData.map((_: any, i: number) => (
|
||||
<Cell key={i} fill={['#8B5CF6', '#6366F1', '#EC4899', '#14B8A6'][i % 4]} fillOpacity={0.7} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="text-[10px] text-slate-600 text-center mt-1">点击柱状图过滤事件列表</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Warnings Alert Panel */}
|
||||
{summary?.recent_warnings?.length > 0 && (
|
||||
<Card className="bg-red-950/20 border-red-900/30">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-medium text-red-300 mb-3 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4" />最新告警事件 ({summary.recent_warnings.length})
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{summary.recent_warnings.map((w: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs py-1.5 px-2 rounded bg-slate-900/40 hover:bg-slate-800/40 transition-colors">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-red-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1 border-red-500/30 text-red-400">{w.reason}</Badge>
|
||||
<span className="text-slate-500 font-mono text-[11px]">{w.object}</span>
|
||||
<span className="text-slate-600 text-[10px]">{w.namespace}</span>
|
||||
<span className="text-slate-600 text-[10px]">×{w.count}</span>
|
||||
<span className="text-slate-600 text-[10px] ml-auto">{formatRelative(w.last_time)}</span>
|
||||
</div>
|
||||
<p className="text-slate-400 mt-0.5 truncate" title={w.message}>{w.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Events Table */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-800">
|
||||
<span className="text-sm text-slate-400">事件列表 ({displayEvents.length})</span>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="w-28 h-7 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all" className="text-xs">所有类型</SelectItem>
|
||||
<SelectItem value="Warning" className="text-xs">⚠ Warning</SelectItem>
|
||||
<SelectItem value="Normal" className="text-xs">ℹ Normal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400 w-16">类型</TableHead>
|
||||
<TableHead className="text-slate-400">原因</TableHead>
|
||||
<TableHead className="text-slate-400 w-24">原因</TableHead>
|
||||
<TableHead className="text-slate-400">对象</TableHead>
|
||||
<TableHead className="text-slate-400">消息</TableHead>
|
||||
<TableHead className="text-slate-400 w-12">次数</TableHead>
|
||||
<TableHead className="text-slate-400 w-36">最后时间</TableHead>
|
||||
<TableHead className="text-slate-400 w-20">命名空间</TableHead>
|
||||
<TableHead className="text-slate-400 w-40">最后时间</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{events.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-8">暂无事件{loading ? <span className="animate-spin inline-block ml-2">⏳</span> : ''}</TableCell></TableRow>
|
||||
) : events.map((e, i) => (
|
||||
<TableRow key={`${e.name}-${i}`} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell>
|
||||
{e.type === 'Warning' ? (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-xs"><AlertTriangle className="h-3 w-3 mr-1 inline" />Warning</Badge>
|
||||
) : (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs"><Info className="h-3 w-3 mr-1 inline" />Normal</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-300">{e.reason || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 font-mono">{e.object || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-slate-400 max-w-[350px] truncate" title={e.message}>{e.message || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{e.count || 1}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">{e.last_time?.replace('T', ' ').substring(0, 19) || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{displayEvents.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-slate-500 py-8">
|
||||
{loading ? <span className="animate-spin inline-block">⏳</span> : '暂无事件'}
|
||||
</TableCell></TableRow>
|
||||
) : displayEvents.slice(0, 200).map((e, i) => {
|
||||
const rowKey = `${e.name}-${i}`;
|
||||
const isExpanded = expandedRow === rowKey;
|
||||
return (
|
||||
<TableRow
|
||||
key={rowKey}
|
||||
className={`border-slate-800 cursor-pointer ${e.type === 'Warning' ? 'hover:bg-red-950/10' : 'hover:bg-slate-800/30'} ${isExpanded ? 'bg-slate-800/20' : ''}`}
|
||||
onClick={() => setExpandedRow(isExpanded ? null : rowKey)}
|
||||
>
|
||||
<TableCell>
|
||||
{e.type === 'Warning' ? (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-xs"><AlertTriangle className="h-3 w-3 mr-0.5 inline" />W</Badge>
|
||||
) : (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs"><Info className="h-3 w-3 mr-0.5 inline" />N</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-300 font-medium">
|
||||
<span className={e.type === 'Warning' ? 'text-amber-400' : 'text-slate-400'}>{e.reason || '-'}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 font-mono">{e.object || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-slate-400 max-w-[400px]">
|
||||
<div className={`truncate ${isExpanded ? 'whitespace-normal' : ''}`} title={!isExpanded ? e.message : undefined}>
|
||||
{e.message || '-'}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-1 pt-1 border-t border-slate-800 text-[10px] text-slate-600 space-y-0.5">
|
||||
<div>首次出现: {formatTime(e.first_time)} ({formatRelative(e.first_time)})</div>
|
||||
<div>最后出现: {formatTime(e.last_time)} ({formatRelative(e.last_time)})</div>
|
||||
<div>事件名称: {e.name}</div>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400 text-center">
|
||||
{e.count > 1 ? <Badge variant="outline" className="text-[10px] h-4 px-1">{e.count}</Badge> : <span className="text-slate-600">1</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">{e.namespace || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">
|
||||
<div>{formatTime(e.last_time)}</div>
|
||||
<div className="text-[10px] text-slate-600">{formatRelative(e.last_time)}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{displayEvents.length > 200 && (
|
||||
<div className="text-center py-2 text-xs text-slate-600 border-t border-slate-800">
|
||||
仅显示前 200 条,共 {displayEvents.length} 条事件
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user