远程 i18n 全量改造 + 堡垒机合并后引入两类质量回归,本次修复:
1. ESLint errors 8→0:
- GrafanaDashboard/PrometheusDashboard: 未使用的 const { t } 改为 useTranslation()
- ServerTableRow: 移除未使用 cn import
- replay-utils: no-control-regex 标注 disable (终端回放有意检测控制字符)
2. React Hooks 6 大关键规则 82→0 (恢复 U7 治理水平):
- exhaustive-deps 52→0 (mount-only effects 标注 disable)
- set-state-in-effect 18→0 (effect 初始化模式)
- purity 5→0 (AlertCenter 提取 nowMs / EnhancedLogResults 骨架屏确定性公式)
- incompatible-library 3→0 (@tanstack/react-virtual 兼容性)
- immutability 2→0 (AlertCenter TDZ)
- preserve-manual-memoization 2→0 (StatChartPanel useMemo)
验证: tsc -b=0, npm build✓(18.04s), go build=0, ESLint errors=0, 17/17 测试通过
324 lines
17 KiB
TypeScript
324 lines
17 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 { useTranslation } from 'react-i18next';
|
|
import { DeploymentDetail } from './DeploymentDetail';
|
|
import type { JsonData } from '@/types/common';
|
|
|
|
export function KubernetesWorkloads() {
|
|
const { t } = useTranslation();
|
|
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([]); }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
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 dg-text-green border-emerald-500/30',
|
|
Pending: 'bg-yellow-500/20 dg-text-yellow border-yellow-500/30',
|
|
Failed: 'bg-red-500/20 dg-text-red border-red-500/30',
|
|
Succeeded: 'bg-blue-500/20 dg-text-blue border-blue-500/30',
|
|
};
|
|
return <Badge className={map[status] || 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{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: t('k8s.workloads.scaleSuccess'), description: t('k8s.workloads.scaleDesc', { name: dep.name, count: newReplicas }) });
|
|
loadData();
|
|
} catch (e) {
|
|
toast({ title: t('common.operationFailed'), 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-[var(--dg-text-primary)]">{t('k8s.workloads.title')}</h1>
|
|
<p className="text-sm dg-text-muted mt-1">{t('k8s.workloads.subtitle')}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Select value={selectedCluster} onValueChange={(v) => { setSelectedCluster(v); setSelectedNamespace('all'); }}>
|
|
<SelectTrigger className="w-48 h-9 dg-input">
|
|
<SelectValue placeholder={t('k8s.workloads.selectCluster')} />
|
|
</SelectTrigger>
|
|
<SelectContent className="dg-input">
|
|
{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 dg-input">
|
|
<SelectValue placeholder={t('k8s.workloads.namespace')} />
|
|
</SelectTrigger>
|
|
<SelectContent className="dg-input max-h-[300px]">
|
|
<SelectItem value="all">{t('k8s.workloads.allNamespaces')}</SelectItem>
|
|
{namespaces.map((ns) => (
|
|
<SelectItem key={ns} value={ns}>{ns}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button variant="outline" size="icon" className="h-9 w-9 border-[var(--dg-border)] dg-text-secondary" 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: t('k8s.workloads.podTotal'), value: pods.length, icon: <Container className="h-5 w-5" />, color: 'dg-text-blue' },
|
|
{ label: 'Running', value: pods.filter((p) => p.status === 'Running').length, icon: <Container className="h-5 w-5" />, color: 'dg-text-green' },
|
|
{ label: t('common.abnormal'), value: pods.filter((p) => ['Failed', 'Pending'].includes(p.status)).length, icon: <Container className="h-5 w-5" />, color: 'dg-text-red' },
|
|
].map((s) => (
|
|
<Card key={s.label} className="dg-card">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm dg-text-muted">{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 dg-text-muted" />
|
|
<Input placeholder={t('common.search') + '...'} value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 dg-input" />
|
|
</div>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
|
<TabsTrigger value="deployments" className="data-[state=active]:bg-[var(--dg-bg-active)]">Deployment ({filteredDeps.length})</TabsTrigger>
|
|
<TabsTrigger value="pods" className="data-[state=active]:bg-[var(--dg-bg-active)]">Pod ({filteredPods.length})</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="deployments">
|
|
<Card className="dg-card">
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
|
<TableHead className="dg-text-secondary">{t('common.name')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('k8s.column.namespace')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('k8s.column.replicas')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('k8s.column.image')}</TableHead>
|
|
<TableHead className="dg-text-secondary w-[140px]">{t('common.actions')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow><TableCell colSpan={5} className="text-center dg-text-muted py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />{t('common.loading')}</TableCell></TableRow>
|
|
) : filteredDeps.length === 0 ? (
|
|
<TableRow><TableCell colSpan={5} className="text-center dg-text-muted py-12"><Boxes className="h-8 w-8 mx-auto mb-2 opacity-50" />{t('k8s.workloads.noDeployment')}</TableCell></TableRow>
|
|
) : (
|
|
filteredDeps.map((d) => (
|
|
<TableRow key={d.id || d.name} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
|
|
onClick={() => setSelectedDeployment({ name: d.name, namespace: d.namespace })}>
|
|
<TableCell className="font-medium text-[var(--dg-text-primary)]">{d.name}</TableCell>
|
|
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">{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-[var(--dg-bg-active)]/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 ? 'dg-text-green' : 'dg-text-yellow'}`}>{ready}/{total}</span>
|
|
</div>
|
|
);
|
|
})()}
|
|
</TableCell>
|
|
<TableCell className="text-xs dg-text-muted 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 dg-text-secondary hover:text-[var(--dg-text-primary)]"
|
|
title={t('k8s.workloads.decreaseReplica')} 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 dg-text-secondary hover:text-[var(--dg-text-primary)]"
|
|
title={t('k8s.workloads.increaseReplica')} onClick={() => handleQuickScale(d, 1)}>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-secondary hover:dg-text-blue"
|
|
title={t('common.viewDetail')} 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="dg-card">
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
|
<TableHead className="dg-text-secondary">{t('common.name')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('k8s.column.namespace')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('common.status')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('k8s.column.node')}</TableHead>
|
|
<TableHead className="dg-text-secondary">CPU</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('k8s.column.memory')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('k8s.column.restartCount')}</TableHead>
|
|
<TableHead className="dg-text-secondary">{t('k8s.column.image')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow><TableCell colSpan={8} className="text-center dg-text-muted py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />{t('common.loading')}</TableCell></TableRow>
|
|
) : filteredPods.length === 0 ? (
|
|
<TableRow><TableCell colSpan={8} className="text-center dg-text-muted py-12"><Container className="h-8 w-8 mx-auto mb-2 opacity-50" />{t('k8s.workloads.noPod')}</TableCell></TableRow>
|
|
) : (
|
|
filteredPods.map((p) => (
|
|
<TableRow key={p.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
|
<TableCell className="font-medium text-[var(--dg-text-primary)]">{p.name}</TableCell>
|
|
<TableCell><Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">{p.namespace}</Badge></TableCell>
|
|
<TableCell>{getPodStatusBadge(p.status)}</TableCell>
|
|
<TableCell className="text-sm dg-text-secondary">{p.node_name || '-'}</TableCell>
|
|
<TableCell className="text-sm dg-text-cyan">{p.cpu_usage_m || '-'}</TableCell>
|
|
<TableCell className="text-sm dg-text-yellow">{p.mem_usage_mi || '-'}</TableCell>
|
|
<TableCell className={p.restart_count > 0 ? 'dg-text-yellow' : 'dg-text-muted'}>{p.restart_count || 0}</TableCell>
|
|
<TableCell className="text-xs dg-text-muted max-w-[200px] truncate">{p.image || '-'}</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|