Files
aiops/frontend/src/components/kubernetes/KubernetesWorkloads.tsx
T
attacker c68aef4444 feat(alert): A2 告警引擎分布式安全 — Redis 分布式锁(SetNX)
- Cache 接口新增 SetNX 方法(go-redis 原子 / redka check-then-set 单副本安全)
- AlertEngine 改造为支持分布式锁: tryAcquireLock/releaseLock/GetEngineStatus
- 多副本部署时仅 Leader 执行告警检查, 60s TTL 防死锁, fail-open 容错
- 新增 GET /alerts/engine/status 监控端点(is_leader/instance_id/lock_holder)
- 修复 GrafanaDashboard.tsx Panel.options 类型不匹配导致构建中断
- 同步未提交的前端类型安全迁移(140+ 文件)
- ROADMAP/TDD_PLAN/PROJECT_EVALUATION 三文档同步更新

验证: go build 零错误, Docker 部署 healthy, 引擎状态端点正常,
锁竞争测试通过(非 Leader 跳过检查), 5/5 核心 API 回归通过
2026-06-26 21:13:15 +08:00

321 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
RefreshCw,
Search,
Boxes,
Loader2,
Container,
Plus,
Minus,
Eye,
} from 'lucide-react';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import { DeploymentDetail } from './DeploymentDetail';
import type { JsonData } from '@/types/common';
export function KubernetesWorkloads() {
const { toast } = useToast();
const [clusters, setClusters] = useState<JsonData[]>([]);
const [selectedCluster, setSelectedCluster] = useState('');
const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNamespace, setSelectedNamespace] = useState('all');
const [deployments, setDeployments] = useState<JsonData[]>([]);
const [pods, setPods] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState('deployments');
const [, setActionLoading] = useState<Record<string, string>>({});
// Detail view
const [selectedDeployment, setSelectedDeployment] = useState<{ name: string; namespace: string } | null>(null);
const loadClusters = useCallback(async () => {
try {
const res: JsonData = await kubernetesApi.getClusters();
const list = res?.list || [];
setClusters(list);
if (list.length > 0 && !selectedCluster) setSelectedCluster(list[0].code || String(list[0].id));
} catch { setClusters([]); }
}, []);
useEffect(() => { loadClusters(); }, [loadClusters]);
// Load namespaces when cluster changes
const loadNamespaces = useCallback(async () => {
if (!selectedCluster) return;
try {
const res: JsonData = await kubernetesApi.getNamespaces(selectedCluster);
const nsList: string[] = Array.isArray(res) ? res : (res?.list || res?.data || []);
setNamespaces(nsList);
} catch { setNamespaces([]); }
}, [selectedCluster]);
useEffect(() => { loadNamespaces(); }, [loadNamespaces]);
const loadData = useCallback(async () => {
if (!selectedCluster) return;
setLoading(true);
const clusterCode = selectedCluster;
try {
if (selectedNamespace !== 'all') {
const [depRes, podRes] = await Promise.all([
kubernetesApi.getNamespaceDeployments(clusterCode, selectedNamespace).catch(() => []),
kubernetesApi.getNamespacePods(clusterCode, selectedNamespace).catch(() => []),
]);
const toArray = (v: JsonData) => (Array.isArray(v) ? v : (v?.list || []));
setDeployments(toArray(depRes));
setPods(toArray(podRes));
} else {
const [depRes, podRes] = await Promise.all([
kubernetesApi.getAllDeployments(clusterCode).catch(() => []),
kubernetesApi.getAllPods(clusterCode).catch(() => []),
]);
const toArray = (v: JsonData) => (Array.isArray(v) ? v : (v?.list || []));
setDeployments(toArray(depRes));
setPods(toArray(podRes));
}
} catch { setDeployments([]); setPods([]); } finally { setLoading(false); }
}, [selectedCluster, selectedNamespace]);
useEffect(() => { loadData(); }, [loadData]);
const filteredDeps = deployments.filter((d) =>
(d.name || '').toLowerCase().includes(search.toLowerCase()) &&
(selectedNamespace === 'all' || d.namespace === selectedNamespace),
);
const filteredPods = pods.filter((p) =>
(p.name || '').toLowerCase().includes(search.toLowerCase()) &&
(selectedNamespace === 'all' || p.namespace === selectedNamespace),
);
const getPodStatusBadge = (status: string) => {
const map: Record<string, string> = {
Running: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
Pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
Failed: 'bg-red-500/20 text-red-400 border-red-500/30',
Succeeded: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
};
return <Badge className={map[status] || 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{status || 'Unknown'}</Badge>;
};
const handleQuickScale = async (dep: JsonData, delta: number) => {
const key = `scale-${dep.name}`;
setActionLoading((prev) => ({ ...prev, [key]: 'loading' }));
try {
const newReplicas = (dep.replicas || 0) + delta;
if (newReplicas < 0) return;
await kubernetesApi.scaleDeployment(selectedCluster, dep.namespace, dep.name, newReplicas);
toast({ title: '扩缩容成功', description: `${dep.name} → ${newReplicas} 副本` });
loadData();
} catch (e) {
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setActionLoading((prev) => { const n = { ...prev }; delete n[key]; return n; });
}
};
// Show detail view if a deployment is selected
if (selectedDeployment) {
return (
<DeploymentDetail
clusterCode={selectedCluster}
namespace={selectedDeployment.namespace}
deploymentName={selectedDeployment.name}
onBack={() => { setSelectedDeployment(null); loadData(); }}
/>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">工作负载</h1>
<p className="text-sm text-slate-500 mt-1">Deployment 与 Pod 运行状态</p>
</div>
<div className="flex items-center gap-2">
<Select value={selectedCluster} onValueChange={(v) => { setSelectedCluster(v); setSelectedNamespace('all'); }}>
<SelectTrigger className="w-48 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="选择集群" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{clusters.map((c) => (
<SelectItem key={c.id} value={c.code || String(c.id)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedNamespace} onValueChange={setSelectedNamespace}>
<SelectTrigger className="w-40 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="命名空间" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 max-h-[300px]">
<SelectItem value="all">全部命名空间</SelectItem>
{namespaces.map((ns) => (
<SelectItem key={ns} value={ns}>{ns}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadData} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: 'Deployment', value: deployments.length, icon: <Boxes className="h-5 w-5" />, color: 'text-rose-400' },
{ label: 'Pod 总数', value: pods.length, icon: <Container className="h-5 w-5" />, color: 'text-blue-400' },
{ label: 'Running', value: pods.filter((p) => p.status === 'Running').length, icon: <Container className="h-5 w-5" />, color: 'text-emerald-400' },
{ label: '异常', value: pods.filter((p) => ['Failed', 'Pending'].includes(p.status)).length, icon: <Container className="h-5 w-5" />, color: 'text-red-400' },
].map((s) => (
<Card key={s.label} className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{s.label}</p>
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
</div>
<div className="opacity-60">{s.icon}</div>
</div>
</CardContent>
</Card>
))}
</div>
<div className="relative w-56">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input placeholder="搜索..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="deployments" className="data-[state=active]:bg-slate-700">Deployment ({filteredDeps.length})</TabsTrigger>
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">Pod ({filteredPods.length})</TabsTrigger>
</TabsList>
<TabsContent value="deployments">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">名称</TableHead>
<TableHead className="text-slate-400">命名空间</TableHead>
<TableHead className="text-slate-400">副本</TableHead>
<TableHead className="text-slate-400">镜像</TableHead>
<TableHead className="text-slate-400 w-[140px]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
) : filteredDeps.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-12"><Boxes className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 Deployment</TableCell></TableRow>
) : (
filteredDeps.map((d) => (
<TableRow key={d.id || d.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer"
onClick={() => setSelectedDeployment({ name: d.name, namespace: d.namespace })}>
<TableCell className="font-medium text-slate-200">{d.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{d.namespace}</Badge></TableCell>
<TableCell>
{(() => {
const ready = d.ready_replicas || 0;
const total = d.replicas || 0;
const pct = total > 0 ? (ready / total) * 100 : 0;
const done = ready === total;
return (
<div className="flex items-center gap-2 min-w-[100px]">
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
<div className={`h-full rounded-full ${done ? 'bg-emerald-500' : 'bg-yellow-500'}`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-xs ${done ? 'text-emerald-400' : 'text-yellow-400'}`}>{ready}/{total}</span>
</div>
);
})()}
</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-slate-200"
title="减少副本" disabled={(d.replicas || 0) <= 0}
onClick={() => handleQuickScale(d, -1)}>
<Minus className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-slate-200"
title="增加副本" onClick={() => handleQuickScale(d, 1)}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
title="查看详情" onClick={() => setSelectedDeployment({ name: d.name, namespace: d.namespace })}>
<Eye className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="pods">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">名称</TableHead>
<TableHead className="text-slate-400">命名空间</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">节点</TableHead>
<TableHead className="text-slate-400">CPU</TableHead>
<TableHead className="text-slate-400">内存</TableHead>
<TableHead className="text-slate-400">重启</TableHead>
<TableHead className="text-slate-400">镜像</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
) : filteredPods.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Container className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 Pod</TableCell></TableRow>
) : (
filteredPods.map((p) => (
<TableRow key={p.id} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{p.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{p.namespace}</Badge></TableCell>
<TableCell>{getPodStatusBadge(p.status)}</TableCell>
<TableCell className="text-sm text-slate-400">{p.node_name || '-'}</TableCell>
<TableCell className="text-sm text-cyan-400">{p.cpu_usage_m || '-'}</TableCell>
<TableCell className="text-sm text-amber-400">{p.mem_usage_mi || '-'}</TableCell>
<TableCell className={p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500'}>{p.restart_count || 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}