feat: PTY kubectl终端 + 侧边栏对齐修复 + 构建缓存清理

**kubectl WebShell 修复:**
- 引入 github.com/creack/pty 库实现真正的伪终端
- 替换 bash --noediting + TERM=dumb 方案为 PTY-backed bash
- 支持 xterm-256color、窗口自适应、readline 交互
- 修复键盘输入无响应问题

**侧边栏优化:**
- 独立菜单项高度从 py-1.5 改为 h-8,与父菜单对齐
- 独立菜单图标从 h-3 w-3 改为 h-3.5 w-3.5,统一 14px

**构建优化:**
- run.sh docker up 增加 docker image prune -f 清理悬空镜像
- 每次部署自动清理 builder cache + dangling images

**认证修复(历史):**
- Token 存储:Zustand + localStorage 双写,http.ts 双源 fallback
- YamlViewer/DeploymentDetail token 来源统一为 Zustand store
This commit is contained in:
attacker committed 2026-06-19 11:22:45 +00:00
1 parent cac1414917
commit a3cd0d3910
29 files changed
+2954 -180

No files matched your search

@@ -0,0 +1,139 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import { RefreshCw, ChevronRight, BarChart3, Download, Cpu, HardDrive } from 'lucide-react';
import { kubernetesApi } from '@/services/api';
export default function CapacityPage() {
const { clusterCode } = useParams<{ clusterCode: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [capacity, setCapacity] = useState<any[]>([]);
const [clusters, setClusters] = useState<any[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNs, setSelectedNs] = useState('all');
const [activeCluster, setActiveCluster] = useState(clusterCode || '');
useEffect(() => {
kubernetesApi.getClusters().then((res: any) => {
const list = res?.list || [];
setClusters(list);
if (!activeCluster && list.length > 0) {
const code = list[0].code || String(list[0].id);
setActiveCluster(code);
navigate(`/kubernetes/${code}/capacity`, { replace: true });
}
}).catch(() => {});
}, []);
useEffect(() => {
if (!activeCluster) return;
kubernetesApi.getNamespaces(activeCluster).then((res: any) => {
setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || []));
}).catch(() => {});
}, [activeCluster]);
const loadCapacity = useCallback(async () => {
if (!activeCluster) return;
setLoading(true);
try {
const ns = selectedNs === 'all' ? undefined : selectedNs;
const res: any = await kubernetesApi.getCapacity(activeCluster, ns);
setCapacity(Array.isArray(res) ? res : (res?.list || []));
} catch { setCapacity([]); } finally { setLoading(false); }
}, [activeCluster, selectedNs]);
useEffect(() => { loadCapacity(); }, [loadCapacity]);
const rows = useMemo(() =>
capacity.flatMap((d: any) =>
(d.containers || []).map((c: any) => ({
namespace: d.namespace,
deployment: d.name,
replicas: d.replicas,
container: c.name,
cpu_req: c.cpu_request || '-',
cpu_lim: c.cpu_limit || '-',
mem_req: c.mem_request || '-',
mem_lim: c.mem_limit || '-',
}))
), [capacity]);
const exportCSV = () => kubernetesApi.exportAsCSV(`capacity-${activeCluster}.csv`, rows);
return (
<div className="space-y-4">
<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>
<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"><BarChart3 className="h-5 w-5 text-purple-400" />资源容量分析</h2>
<div className="flex items-center gap-2">
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/capacity`); }}>
<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>
<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>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={loadCapacity} disabled={loading}>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
</Button>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={exportCSV}>
<Download className="h-3 w-3 mr-1" />CSV
</Button>
</div>
</div>
<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">Deployment</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 className="h-3 w-3 inline mr-1" />CPU Req</TableHead>
<TableHead className="text-slate-400"><Cpu className="h-3 w-3 inline mr-1" />CPU Lim</TableHead>
<TableHead className="text-slate-400"><HardDrive className="h-3 w-3 inline mr-1" />Mem Req</TableHead>
<TableHead className="text-slate-400"><HardDrive className="h-3 w-3 inline mr-1" />Mem Lim</TableHead>
</TableRow></TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-8">暂无数据{loading ? <span className="animate-spin inline-block ml-2">⏳</span> : ''}</TableCell></TableRow>
) : rows.map((r, i) => (
<TableRow key={`${r.deployment}-${r.container}-${i}`} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200 text-sm">{r.deployment}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">{r.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-300">{r.replicas}</TableCell>
<TableCell className="text-sm text-slate-400">{r.container}</TableCell>
<TableCell className={`text-sm ${r.cpu_req !== '-' ? 'text-blue-400' : 'text-slate-600'}`}>{r.cpu_req}</TableCell>
<TableCell className={`text-sm ${r.cpu_lim !== '-' ? 'text-cyan-400' : 'text-slate-600'}`}>{r.cpu_lim}</TableCell>
<TableCell className={`text-sm ${r.mem_req !== '-' ? 'text-emerald-400' : 'text-slate-600'}`}>{r.mem_req}</TableCell>
<TableCell className={`text-sm ${r.mem_lim !== '-' ? 'text-teal-400' : 'text-slate-600'}`}>{r.mem_lim}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,229 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
RefreshCw, Loader2, Cpu, Server, Container,
ChevronRight, Cloud, Boxes, Network, FileText, Shield, Activity, Clock,
} from 'lucide-react';
import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
const NAMESPACE_ICON: Record<string, React.ComponentType<any>> = {
'kube-system': Server,
'kube-public': Shield,
'kube-node-lease': Activity,
'default': Cloud,
};
export default function ClusterOverviewPage() {
const { clusterCode } = useParams<{ clusterCode: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const [clusters, setClusters] = useState<any[]>([]);
const [nodes, setNodes] = useState<any[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [clusterInfo, setClusterInfo] = useState<any>(null);
const [loading, setLoading] = useState(false);
// Load cluster list + current cluster info
const loadClusterInfo = useCallback(async () => {
try {
const res: any = await kubernetesApi.getClusters();
const list = res?.list || [];
setClusters(list);
const current = list.find((c: any) => (c.code || String(c.id)) === clusterCode);
setClusterInfo(current || null);
if (!current && list.length > 0 && clusterCode) {
toast({ title: '集群未找到', description: `找不到集群 ${clusterCode}`, variant: 'destructive' });
}
} catch { setClusters([]); }
}, [clusterCode]);
const loadData = useCallback(async () => {
if (!clusterCode) return;
setLoading(true);
try {
const [nodesRes, nsRes] = await Promise.all([
kubernetesApi.getNodes(clusterCode).catch(() => []),
kubernetesApi.getNamespaces(clusterCode).catch(() => []),
]);
setNodes(Array.isArray(nodesRes) ? nodesRes : (nodesRes?.list || nodesRes?.data || []));
const nsList: string[] = Array.isArray(nsRes) ? nsRes : (nsRes?.list || nsRes?.data || []);
setNamespaces(nsList);
} catch { setNodes([]); setNamespaces([]); } finally { setLoading(false); }
}, [clusterCode]);
useEffect(() => { loadClusterInfo(); }, [loadClusterInfo]);
useEffect(() => { loadData(); }, [loadData]);
const getNodeRole = (node: any) => {
if (node?.role === 'master' || node?.role === 'control-plane') return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
};
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 transition-colors">集群</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-slate-300">{clusterCode}</span>
</div>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">{clusterCode}</h1>
<p className="text-sm text-slate-500 mt-1">
集群概览
{clusterInfo?.version ? ` — Kubernetes ${clusterInfo.version}` : ''}
</p>
</div>
<div className="flex items-center gap-2">
<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>
{/* Stats cards */}
<div className="grid grid-cols-3 md:grid-cols-3 gap-4">
<Card 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">节点</p><p className="text-2xl font-bold text-blue-400">{nodes.length}</p></div>
<Cpu className="h-8 w-8 opacity-30 text-blue-400" />
</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-sm text-slate-500">命名空间</p><p className="text-2xl font-bold text-emerald-400">{namespaces.length}</p></div>
<Boxes className="h-8 w-8 opacity-30 text-emerald-400" />
</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-sm text-slate-500">版本</p><p className="text-lg font-bold text-slate-300">{clusterInfo?.version || '-'}</p></div>
<Server className="h-8 w-8 opacity-30 text-slate-400" />
</div>
</CardContent>
</Card>
</div>
{/* Nodes table */}
<div>
<h2 className="text-lg font-semibold text-slate-100 mb-3 flex items-center gap-2">
<Cpu className="h-4 w-4 text-blue-400" />节点列表
</h2>
<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">IP</TableHead>
<TableHead className="text-slate-400">OS</TableHead>
<TableHead className="text-slate-400">Kubelet</TableHead>
<TableHead className="text-slate-400">CPU</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>
) : nodes.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Cpu className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无节点</TableCell></TableRow>
) : (
nodes.map((n: any) => {
const cpuCores = n.cpu || 0;
const cpuUsageM = n.cpu_usage_m ? parseFloat(n.cpu_usage_m.replace('m','')) : 0;
const cpuPct = cpuCores > 0 ? (cpuUsageM / 1000 / cpuCores) * 100 : 0;
const memMb = n.memory_mb || 0;
const memUsageMi = n.mem_usage_mi ? parseFloat(n.mem_usage_mi.replace('Mi','').replace('Ki','').replace('Gi','')) : 0;
// Normalize to MiB
const memUsageNorm = n.mem_usage_mi?.includes('Ki') ? memUsageMi / 1024 : n.mem_usage_mi?.includes('Gi') ? memUsageMi * 1024 : memUsageMi;
const memPct = memMb > 0 ? (memUsageNorm / memMb) * 100 : 0;
const color = (pct: number) => pct > 80 ? 'bg-red-500' : pct > 60 ? 'bg-yellow-500' : 'bg-emerald-500';
return (
<TableRow key={n.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{n.name}</TableCell>
<TableCell><Badge className={getNodeRole(n)}>{n.role || 'worker'}</Badge></TableCell>
<TableCell><Badge className={n.status === 'Ready' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}>{n.status || 'Unknown'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{n.ip || '-'}</TableCell>
<TableCell className="text-sm text-slate-400 max-w-[200px] truncate">{n.os || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{n.kubelet_version || '-'}</TableCell>
<TableCell className="text-xs">
{n.cpu_usage_m ? (
<div className="flex items-center gap-1.5 min-w-[80px]">
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
<div className={`h-full rounded-full ${color(cpuPct)}`} style={{ width: `${Math.min(cpuPct, 100)}%` }} />
</div>
<span className="text-slate-400">{cpuPct.toFixed(0)}%</span>
</div>
) : (
<span className="text-slate-500">{cpuCores} 核</span>
)}
</TableCell>
<TableCell className="text-xs">
{n.mem_usage_mi ? (
<div className="flex items-center gap-1.5 min-w-[80px]">
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
<div className={`h-full rounded-full ${color(memPct)}`} style={{ width: `${Math.min(memPct, 100)}%` }} />
</div>
<span className="text-slate-400">{memPct.toFixed(0)}%</span>
</div>
) : (
<span className="text-slate-500">{memMb}Mi</span>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
{/* Namespace list */}
<div>
<h2 className="text-lg font-semibold text-slate-100 mb-3 flex items-center gap-2">
<Boxes className="h-4 w-4 text-emerald-400" />命名空间
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{namespaces.map((ns) => {
const Icon = NAMESPACE_ICON[ns] || Container;
return (
<Link key={ns}
to={`/kubernetes/${clusterCode}/namespace/${ns}`}
className="bg-slate-900 border border-slate-800 rounded-lg p-4 hover:border-slate-600 hover:bg-slate-800/50 transition-all group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-slate-500 group-hover:text-slate-300 transition-colors" />
<span className="text-sm font-medium text-slate-300 group-hover:text-slate-100 transition-colors">{ns}</span>
</div>
<ChevronRight className="h-4 w-4 text-slate-600 group-hover:text-slate-400 transition-colors" />
</div>
</Link>
);
})}
</div>
</div>
</div>
);
}
@@ -16,6 +16,7 @@ import {
} from 'lucide-react';
import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { useUserStore } from '@/store';
interface DeploymentDetailProps {
clusterCode: string;
@@ -69,10 +70,9 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
);
setPods(depPods);
// Filter HPAs targeting this deployment
// Filter HPAs targeting this deployment (flattened data)
const depHpas = allHpas.filter((h: any) => {
const ref = h.spec?.scaleTargetRef;
return ref?.name === deploymentName && h.metadata?.namespace === namespace;
return h.target_name === deploymentName && h.namespace === namespace;
});
setHpas(depHpas);
} catch (e) {
@@ -134,9 +134,9 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
const handleHpaToggle = async (hpa: any) => {
// Not direct toggle — delete HPA
if (!confirm(`确定删除 HPA "${hpa.metadata?.name}"?`)) return;
if (!confirm(`确定删除 HPA "${hpa.name}"?`)) return;
try {
await kubernetesApi.deleteHPA(clusterCode, namespace, hpa.metadata?.name);
await kubernetesApi.deleteHPA(clusterCode, namespace, hpa.name);
toast({ title: 'HPA 已删除' });
loadData();
} catch (e: any) {
@@ -406,7 +406,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
onClick={async () => {
setLogs('');
// Stream mode
const token = localStorage.getItem('token') || '';
const token = useUserStore.getState().token || '';
const url = `${window.location.protocol}//${window.location.host}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${logPod}/logs?tail=${logTail}&stream=true`;
try {
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
@@ -471,18 +471,18 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
</TableHeader>
<TableBody>
{hpas.map((hpa: any) => {
const spec = hpa.spec || {};
const status = hpa.status || {};
const metrics = (spec.metrics || []).map((m: any) => {
if (m.resource) return `${m.resource.name}: ${m.resource.target.averageUtilization}%`;
return m.type;
}).join(', ');
const cpu = hpa.cpu_percent || 0;
const mem = hpa.mem_percent || 0;
const metricsParts = [];
if (cpu) metricsParts.push(`CPU: ${cpu}%`);
if (mem) metricsParts.push(`MEM: ${mem}%`);
const metrics = metricsParts.join(', ') || '-';
return (
<TableRow key={hpa.metadata?.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{hpa.metadata?.name}</TableCell>
<TableCell className="text-slate-300">{spec.minReplicas} / {spec.maxReplicas}</TableCell>
<TableCell className="text-sm text-slate-400">{metrics || '-'}</TableCell>
<TableCell className="text-slate-300">{status.currentReplicas || 0} / {status.desiredReplicas || 0}</TableCell>
<TableRow key={hpa.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{hpa.name}</TableCell>
<TableCell className="text-slate-300">{hpa.min_replicas} / {hpa.max_replicas}</TableCell>
<TableCell className="text-sm text-slate-400">{metrics}</TableCell>
<TableCell className="text-slate-300">{hpa.current_replicas || 0}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300"
onClick={() => handleHpaToggle(hpa)} title="删除 HPA">
@@ -0,0 +1,18 @@
import { useParams, useNavigate } from 'react-router-dom';
import { DeploymentDetail } from './DeploymentDetail';
export default function DeploymentDetailPage() {
const { clusterCode, namespace, name } = useParams<{
clusterCode: string; namespace: string; name: string;
}>();
const navigate = useNavigate();
return (
<DeploymentDetail
clusterCode={clusterCode || ''}
namespace={namespace || ''}
deploymentName={name || ''}
onBack={() => navigate(-1)}
/>
);
}
@@ -0,0 +1,147 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import { RefreshCw, ChevronRight, Bell, AlertTriangle, Info } from 'lucide-react';
import { kubernetesApi } from '@/services/api';
export default function EventsPage() {
const { clusterCode } = useParams<{ clusterCode: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<any[]>([]);
const [clusters, setClusters] = useState<any[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNs, setSelectedNs] = useState('all');
const [filterType, setFilterType] = useState('all');
const [activeCluster, setActiveCluster] = useState(clusterCode || '');
useEffect(() => {
kubernetesApi.getClusters().then((res: any) => {
const list = res?.list || [];
setClusters(list);
if (!activeCluster && list.length > 0) {
const code = list[0].code || String(list[0].id);
setActiveCluster(code);
navigate(`/kubernetes/${code}/events`, { replace: true });
}
}).catch(() => {});
}, []);
useEffect(() => {
if (!activeCluster) return;
kubernetesApi.getNamespaces(activeCluster).then((res: any) => {
setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || []));
}).catch(() => {});
}, [activeCluster]);
const loadEvents = useCallback(async () => {
if (!activeCluster) return;
setLoading(true);
try {
const ns = selectedNs === 'all' ? undefined : selectedNs;
const ft = filterType === 'all' ? undefined : filterType;
const res: any = await kubernetesApi.getEvents(activeCluster, ns, ft);
setEvents(Array.isArray(res) ? res : (res?.list || []));
} catch { setEvents([]); } finally { setLoading(false); }
}, [activeCluster, selectedNs, filterType]);
useEffect(() => { loadEvents(); }, [loadEvents]);
const warningCount = useMemo(() => events.filter(e => e.type === 'Warning').length, [events]);
const normalCount = useMemo(() => events.filter(e => e.type === 'Normal').length, [events]);
return (
<div className="space-y-4">
<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>
<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">
<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>
<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>
<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>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={loadEvents} 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>
</div>
<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 w-16">类型</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-12">次数</TableHead>
<TableHead className="text-slate-400 w-36">最后时间</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>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,211 @@
import { useState, useRef, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { Terminal, Maximize2, Minimize2, X, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUserStore } from '@/store';
interface KubectlTerminalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
clusterId: number | string;
clusterName: string;
}
export function KubectlTerminal({ open, onOpenChange, clusterId, clusterName }: KubectlTerminalProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [error, setError] = useState('');
// 重置全屏状态
useEffect(() => {
if (!open) setFullscreen(false);
}, [open]);
// 连接终端
useEffect(() => {
if (!open || !clusterId) return;
const term = new XTerm({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true,
theme: {
background: '#0f172a',
foreground: '#e2e8f0',
cursor: '#38bdf8',
selectionBackground: '#1e40af',
black: '#1e293b', red: '#ef4444', green: '#10b981',
yellow: '#f59e0b', blue: '#3b82f6', magenta: '#a855f7',
cyan: '#06b6d4', white: '#e2e8f0',
brightBlack: '#475569', brightRed: '#f87171', brightGreen: '#34d399',
brightYellow: '#fbbf24', brightBlue: '#60a5fa', brightMagenta: '#c084fc',
brightCyan: '#22d3ee', brightWhite: '#f8fafc',
},
});
const fit = new FitAddon();
term.loadAddon(fit);
termRef.current = term;
fitRef.current = fit;
// WebSocket
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${proto}//${window.location.host}`;
const token = useUserStore.getState().token || '';
const wsUrl = `${baseUrl.replace(/^http/, 'ws').replace('/api/v1', '')}/ws/kubectl?token=${encodeURIComponent(token)}&id=${clusterId}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ status: 0, data }));
}
});
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
term.write(msg.data);
}
} catch {
if (typeof event.data === 'string') term.write(event.data);
}
};
ws.onopen = () => {
setConnected(true);
setError('');
if (containerRef.current) {
term.open(containerRef.current);
requestAnimationFrame(() => {
try {
fit.fit();
} catch { /* noop */ }
});
// 延迟 focus:确保 Dialog 渲染完成后再聚焦终端
setTimeout(() => {
try {
term.focus();
} catch { /* noop */ }
}, 150);
}
};
ws.onclose = () => {
setConnected(false);
term.write('\r\n\x1b[31m[连接已断开]\x1b[0m\r\n');
};
ws.onerror = () => {
setError('WebSocket 连接失败');
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
return () => {
try {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
term.dispose();
} catch { /* noop */ }
termRef.current = null;
wsRef.current = null;
setConnected(false);
};
}, [open, clusterId]);
// ResizeObserver 监听容器变化自动 fit
useEffect(() => {
if (!open) return;
const container = containerRef.current;
if (!container) return;
let resizeTimer: ReturnType<typeof setTimeout>;
const ro = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (fitRef.current && termRef.current && wsRef.current) {
try {
fitRef.current.fit();
termRef.current.focus();
const { cols, rows } = termRef.current;
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch { /* noop */ }
}
}, 60);
});
ro.observe(container);
return () => {
clearTimeout(resizeTimer);
ro.disconnect();
};
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
: ''
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
>
<DialogTitle className="sr-only">kubectl — {clusterName}</DialogTitle>
<DialogDescription className="sr-only">Kubernetes 集群命令行终端</DialogDescription>
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm">
<div className={cn('w-2 h-2 rounded-full', connected ? 'bg-emerald-400' : error ? 'bg-red-400' : 'bg-yellow-400')} />
<Terminal className="h-4 w-4 text-blue-400" />
<span className="text-[var(--dg-text-primary)] font-medium">kubectl — {clusterName}</span>
{!connected && !error && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 终端容器 */}
<div
ref={containerRef}
className="bg-[#0f172a]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
</DialogContent>
</Dialog>
);
}
@@ -26,11 +26,13 @@ import {
Eye,
Trash2,
Search,
Container,
} from 'lucide-react';
import { kubernetesApi } from '@/services/api';
import type { KubernetesCluster } from '@/types';
import { KubernetesClusterDetail } from './KubernetesClusterDetail';
import { KubernetesClusterDialog } from './KubernetesClusterDialog';
import { KubectlTerminal } from './KubectlTerminal';
export function KubernetesClusters() {
const [clusters, setClusters] = useState<any[]>([]);
@@ -42,6 +44,10 @@ export function KubernetesClusters() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingCluster, setEditingCluster] = useState<any>(null);
// kubectl 终端
const [kubectlCluster, setKubectlCluster] = useState<{id: number; name: string} | null>(null);
const [kubectlTerminalOpen, setKubectlTerminalOpen] = useState(false);
const loadData = async () => {
try {
setLoading(true);
@@ -273,6 +279,17 @@ export function KubernetesClusters() {
<Settings className="h-4 w-4 mr-2" />
配置
</DropdownMenuItem>
<DropdownMenuItem
className="text-emerald-400 focus:bg-slate-700 focus:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
setKubectlCluster({ id: cluster.id, name: cluster.name });
setKubectlTerminalOpen(true);
}}
>
<Container className="h-4 w-4 mr-2" />
kubectl
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
@@ -355,6 +372,19 @@ export function KubernetesClusters() {
editingCluster={editingCluster}
onSaved={loadData}
/>
{/* kubectl 终端 */}
{kubectlCluster && (
<KubectlTerminal
open={kubectlTerminalOpen}
onOpenChange={(open) => {
setKubectlTerminalOpen(open);
if (!open) setKubectlCluster(null);
}}
clusterId={kubectlCluster.id}
clusterName={kubectlCluster.name}
/>
)}
</div>
);
}
@@ -72,8 +72,8 @@ export function KubernetesWorkloads() {
setPods(toArray(podRes));
} else {
const [depRes, podRes] = await Promise.all([
kubernetesApi.getDeployments(clusterCode).catch(() => []),
kubernetesApi.getPods(clusterCode).catch(() => []),
kubernetesApi.getAllDeployments(clusterCode).catch(() => []),
kubernetesApi.getAllPods(clusterCode).catch(() => []),
]);
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
setDeployments(toArray(depRes));
@@ -0,0 +1,97 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import {
RefreshCw, Loader2, ChevronRight, Cloud,
Boxes, Container, Network, FileText, Shield, Activity, Clock,
} from 'lucide-react';
import { kubernetesApi } from '@/services/api';
const RESOURCE_LINKS = [
{ type: 'deployments', label: 'Deployments', icon: Boxes, desc: '工作负载副本管理' },
{ type: 'pods', label: 'Pods', icon: Container, desc: '容器实例运行状态' },
{ type: 'cronjobs', label: 'CronJobs', icon: Clock, desc: '定时任务管理' },
{ type: 'services', label: 'Services', icon: Network, desc: '服务发现与负载均衡' },
{ type: 'ingresses', label: 'Ingresses', icon: Network, desc: '外部流量路由规则' },
{ type: 'configmaps', label: 'ConfigMaps', icon: FileText, desc: '配置字典' },
{ type: 'secrets', label: 'Secrets', icon: Shield, desc: '密钥与凭证' },
{ type: 'hpas', label: 'HPAs', icon: Activity, desc: '水平自动伸缩策略' },
];
export default function NamespaceOverviewPage() {
const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>();
const [counts, setCounts] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(false);
const loadCounts = useCallback(async () => {
if (!clusterCode || !namespace) return;
setLoading(true);
try {
const results = await Promise.allSettled([
kubernetesApi.getNamespaceDeployments(clusterCode, namespace!),
kubernetesApi.getNamespacePods(clusterCode, namespace!),
kubernetesApi.getNamespaceServices(clusterCode, namespace!),
kubernetesApi.getNamespaceIngresses(clusterCode, namespace!),
kubernetesApi.getNamespaceConfigMaps(clusterCode, namespace!),
kubernetesApi.getNamespaceSecrets(clusterCode, namespace!),
kubernetesApi.getHPAs(clusterCode, namespace!),
kubernetesApi.getCronJobs(clusterCode, namespace!),
]);
const toArr = (v: any) => Array.isArray(v) ? v : (v?.list || v?.data || []);
setCounts({
deployments: toArr(results[0].status === 'fulfilled' ? results[0].value : []).length,
pods: toArr(results[1].status === 'fulfilled' ? results[1].value : []).length,
services: toArr(results[2].status === 'fulfilled' ? results[2].value : []).length,
ingresses: toArr(results[3].status === 'fulfilled' ? results[3].value : []).length,
configmaps: toArr(results[4].status === 'fulfilled' ? results[4].value : []).length,
secrets: toArr(results[5].status === 'fulfilled' ? results[5].value : []).length,
hpas: toArr(results[6].status === 'fulfilled' ? results[6].value : []).length,
cronjobs: toArr(results[7].status === 'fulfilled' ? results[7].value : []).length,
});
} catch { setCounts({}); } finally { setLoading(false); }
}, [clusterCode, namespace]);
useEffect(() => { loadCounts(); }, [loadCounts]);
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 transition-colors">集群</Link>
<ChevronRight className="h-3 w-3" />
<Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-slate-300">{namespace}</span>
</div>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100 flex items-center gap-2">
<Cloud className="h-6 w-6 text-blue-400" />
{namespace}
</h1>
<p className="text-sm text-slate-500 mt-1">命名空间概览</p>
</div>
</div>
{/* Resource grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{RESOURCE_LINKS.map((r) => (
<Link key={r.type}
to={`/kubernetes/${clusterCode}/namespace/${namespace}/${r.type}`}
className="bg-slate-900 border border-slate-800 rounded-lg p-4 hover:border-slate-600 hover:bg-slate-800/50 transition-all group"
>
<div className="flex items-center justify-between mb-2">
<r.icon className="h-5 w-5 text-slate-500 group-hover:text-blue-400 transition-colors" />
<ChevronRight className="h-4 w-4 text-slate-600 group-hover:text-slate-400 transition-colors" />
</div>
<div className="text-lg font-bold text-slate-100">{loading ? '-' : (counts[r.type] ?? 0)}</div>
<div className="text-sm text-slate-500">{r.label}</div>
<div className="text-xs text-slate-600 mt-1">{r.desc}</div>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,377 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
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 {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
RefreshCw, Search, Loader2, Boxes, Container,
Network, FileText, Shield, Activity, Clock, ChevronRight,
Download, FileCode,
} from 'lucide-react';
import { YamlViewer } from './YamlViewer';
import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
type ResourceType =
| 'deployments' | 'pods' | 'services' | 'ingresses'
| 'configmaps' | 'secrets' | 'hpas' | 'cronjobs'
| 'pvcs' | 'statefulsets' | 'daemonsets';
const RESOURCE_CONFIG: Record<ResourceType, {
title: string;
subtitle: string;
icon: React.ComponentType<any>;
columns: string[];
renderRow: (item: any, opts: { clusterCode: string; namespace: string; onDetail?: (item: any) => void; onYaml?: (name: string) => void }) => React.ReactNode;
}> = {
deployments: {
title: 'Deployments', subtitle: 'Deployment 工作负载运行状态', icon: Boxes,
columns: ['名称', '命名空间', '副本', '镜像', '操作'],
renderRow: (d, opts) => (
<TableRow key={d.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer" onClick={() => opts.onDetail?.(d)}>
<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-blue-400" title="查看YAML"
onClick={() => opts.onYaml?.(d.name)}><FileCode className="h-3.5 w-3.5" /></Button>
</div>
</TableCell>
</TableRow>
),
},
pods: {
title: 'Pods', subtitle: 'Pod 实例运行状态', icon: Container,
columns: ['名称', '命名空间', '状态', '节点', '重启', '镜像', '操作'],
renderRow: (p, opts) => {
const statusMap: 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' };
return (
<TableRow key={p.name} 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><Badge className={statusMap[p.status] || 'bg-slate-500/20 text-slate-400'}>{p.status || 'Unknown'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{p.node_name || '-'}</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>
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
</TableRow>
);
},
},
services: {
title: 'Services', subtitle: 'Service 网络服务', icon: Network,
columns: ['名称', '命名空间', '类型', 'Cluster IP', '端口', '操作'],
renderRow: (s, opts) => (
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{s.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
<TableCell><Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30">{s.type || '-'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{s.cluster_ip || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{s.ports || '-'}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(s.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
</TableRow>
),
},
ingresses: {
title: 'Ingresses', subtitle: 'Ingress 路由规则', icon: Network,
columns: ['名称', '命名空间', 'Hosts', 'TLS', '操作'],
renderRow: (i, opts) => {
const hosts = i.hosts || [];
return (
<TableRow key={i.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{i.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{i.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400 max-w-[250px] truncate">{hosts.length > 0 ? hosts.join(', ') : '-'}</TableCell>
<TableCell><Badge className={i.tls ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-500/20 text-slate-400'}>{i.tls ? 'HTTPS' : 'HTTP'}</Badge></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(i.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
</TableRow>
);
},
},
configmaps: {
title: 'ConfigMaps', subtitle: '配置字典', icon: FileText,
columns: ['名称', '命名空间', '键数量', '操作'],
renderRow: (c, opts) => (
<TableRow key={c.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{c.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{c.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{c.key_count || '-'}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(c.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
</TableRow>
),
},
secrets: {
title: 'Secrets', subtitle: '密钥管理(仅名称与类型)', icon: Shield,
columns: ['名称', '命名空间', '类型', '操作'],
renderRow: (s, opts) => (
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{s.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{s.type || '-'}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(s.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
</TableRow>
),
},
hpas: {
title: 'HPAs', subtitle: '水平自动伸缩', icon: Activity,
columns: ['名称', '命名空间', '目标', '最小/最大', 'CPU%', 'Mem%'],
renderRow: (h, opts) => (
<TableRow key={h.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{h.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{h.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{h.target_kind}/{h.target_name}</TableCell>
<TableCell className="text-sm text-slate-400">{h.min_replicas}/{h.max_replicas}</TableCell>
<TableCell className="text-sm text-slate-400">{h.cpu_percent ?? '-'}%</TableCell>
<TableCell className="text-sm text-slate-400">{h.mem_percent ?? '-'}%</TableCell>
</TableRow>
),
},
cronjobs: {
title: 'CronJobs', subtitle: '定时任务', icon: Clock,
columns: ['名称', '命名空间', '调度', '状态', '上次执行'],
renderRow: (cj) => (
<TableRow key={cj.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{cj.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{cj.namespace}</Badge></TableCell>
<TableCell className="text-sm font-mono text-slate-400">{cj.schedule || '-'}</TableCell>
<TableCell><Badge className={cj.suspend ? 'bg-yellow-500/20 text-yellow-400' : 'bg-emerald-500/20 text-emerald-400'}>{cj.suspend ? '已暂停' : '运行中'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{cj.last_schedule || '-'}</TableCell>
</TableRow>
),
},
pvcs: {
title: '存储卷声明', subtitle: 'PersistentVolumeClaim', icon: Boxes,
columns: ['名称', '命名空间', '状态', '大小', 'StorageClass'],
renderRow: (p, opts) => (
<TableRow key={p.name} 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><Badge className={p.phase === 'Bound' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-yellow-500/20 text-yellow-400'}>{p.phase || '-'}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{p.size || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{p.storage_class || '-'}</TableCell>
</TableRow>
),
},
statefulsets: {
title: 'StatefulSets', subtitle: '有状态应用', icon: Boxes,
columns: ['名称', '命名空间', '副本', '就绪', '镜像'],
renderRow: (s) => (
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{s.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{s.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{s.replicas || 0}</TableCell>
<TableCell className="text-sm text-slate-400">{s.ready || 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{s.image || '-'}</TableCell>
</TableRow>
),
},
daemonsets: {
title: 'DaemonSets', subtitle: '守护进程集', icon: Boxes,
columns: ['名称', '命名空间', '期望', '当前', '就绪', '镜像'],
renderRow: (d) => (
<TableRow key={d.name} className="border-slate-800 hover:bg-slate-800/30">
<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 className="text-sm text-slate-400">{d.desired || 0}</TableCell>
<TableCell className="text-sm text-slate-400">{d.current || 0}</TableCell>
<TableCell className="text-sm text-slate-400">{d.ready || 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
</TableRow>
),
},
};
export default function NamespaceResourcePage() {
const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>();
const location = useLocation();
const navigate = useNavigate();
const { toast } = useToast();
const type = useMemo<ResourceType>(() => {
const segments = location.pathname.split('/');
const last = segments[segments.length - 1];
if (last && RESOURCE_CONFIG[last as ResourceType]) return last as ResourceType;
if (segments.length >= 2) {
const secondLast = segments[segments.length - 2];
if (secondLast && RESOURCE_CONFIG[secondLast as ResourceType]) return secondLast as ResourceType;
}
return 'deployments';
}, [location.pathname]);
const config = RESOURCE_CONFIG[type];
const [clusters, setClusters] = useState<any[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [yamlItem, setYamlItem] = useState<{resource: string; name: string} | null>(null);
useEffect(() => {
kubernetesApi.getClusters().then((res: any) => {
const list = res?.list || [];
setClusters(list);
if (!clusterCode && list.length > 0) {
const firstCode = list[0].code || String(list[0].id);
navigate(`/kubernetes/${firstCode}/namespace/${namespace || 'default'}/${type}`, { replace: true });
}
}).catch(() => setClusters([]));
}, []);
useEffect(() => {
if (!clusterCode) return;
kubernetesApi.getNamespaces(clusterCode).then((res: any) => {
const nsList: string[] = Array.isArray(res) ? res : (res?.list || res?.data || []);
setNamespaces(nsList);
if (!namespace && nsList.length > 0) {
navigate(`/kubernetes/${clusterCode}/namespace/${nsList[0]}/${type}`, { replace: true });
}
}).catch(() => setNamespaces([]));
}, [clusterCode]);
const loadData = useCallback(async () => {
if (!clusterCode || !namespace) return;
setLoading(true);
try {
let res: any = [];
switch (type) {
case 'deployments': res = await kubernetesApi.getNamespaceDeployments(clusterCode, namespace).catch(() => []); break;
case 'pods': res = await kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []); break;
case 'services': res = await kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []); break;
case 'ingresses': res = await kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []); break;
case 'configmaps': res = await kubernetesApi.getNamespaceConfigMaps(clusterCode, namespace).catch(() => []); break;
case 'secrets': res = await kubernetesApi.getNamespaceSecrets(clusterCode, namespace).catch(() => []); break;
case 'hpas': res = await kubernetesApi.getHPAs(clusterCode, namespace).catch(() => []); break;
case 'cronjobs': res = await kubernetesApi.getCronJobs(clusterCode, namespace).catch(() => []); break;
case 'pvcs': res = await kubernetesApi.getPVCs(clusterCode, namespace).catch(() => []); break;
case 'statefulsets': res = await kubernetesApi.getStatefulSets(clusterCode, namespace).catch(() => []); break;
case 'daemonsets': res = await kubernetesApi.getDaemonSets(clusterCode, namespace).catch(() => []); break;
}
setData(Array.isArray(res) ? res : (res?.list || res?.data || []));
} catch { setData([]); } finally { setLoading(false); }
}, [clusterCode, namespace, type]);
useEffect(() => { loadData(); }, [loadData]);
const filtered = data.filter((d: any) => (d.name || '').toLowerCase().includes(search.toLowerCase()));
const handleDetail = (item: any) => {
if (type === 'deployments') {
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${item.name}`);
}
};
const handleYaml = (name: string) => setYamlItem({ resource: type, name });
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered);
const sidebarResources = useMemo(() => [
{ type: 'deployments' as ResourceType, label: 'Deployments', icon: Boxes },
{ type: 'statefulsets' as ResourceType, label: 'StatefulSets', icon: Boxes },
{ type: 'daemonsets' as ResourceType, label: 'DaemonSets', icon: Boxes },
{ type: 'pods' as ResourceType, label: 'Pods', icon: Container },
{ type: 'cronjobs' as ResourceType, label: 'CronJobs', icon: Clock },
{ type: 'services' as ResourceType, label: 'Services', icon: Network },
{ type: 'ingresses' as ResourceType, label: 'Ingresses', icon: Network },
{ type: 'configmaps' as ResourceType, label: 'ConfigMaps', icon: FileText },
{ type: 'secrets' as ResourceType, label: 'Secrets', icon: Shield },
{ type: 'hpas' as ResourceType, label: 'HPAs', icon: Activity },
{ type: 'pvcs' as ResourceType, label: '存储卷', icon: Boxes },
], []);
if (!config) return <div className="text-slate-400 p-8 text-center">未知资源类型: {type}</div>;
return (
<div className="space-y-4">
{yamlItem && (
<YamlViewer
clusterCode={clusterCode || ''}
namespace={namespace || ''}
resource={yamlItem.resource}
name={yamlItem.name}
onClose={() => setYamlItem(null)}
onSaved={loadData}
/>
)}
<div className="flex items-center gap-2 text-sm text-slate-500">
<Link to="/kubernetes" className="hover:text-slate-300 transition-colors">集群</Link>
{clusterCode && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${clusterCode}`} className="hover:text-slate-300 transition-colors">{clusterCode}</Link></>}
{namespace && <><ChevronRight className="h-3 w-3" /><Link to={`/kubernetes/${clusterCode}/namespace/${namespace}`} className="hover:text-slate-300 transition-colors">{namespace}</Link></>}
<ChevronRight className="h-3 w-3" /><span className="text-slate-300">{config.title}</span>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">{config.title}</h1>
<p className="text-sm text-slate-500 mt-1">{config.subtitle}</p>
</div>
<div className="flex items-center gap-2">
<Select value={clusterCode || ''} onValueChange={(v) => navigate(`/kubernetes/${v}/namespace/${namespace || 'default'}/${type}`)}>
<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={namespace || ''} onValueChange={(v) => navigate(`/kubernetes/${clusterCode}/namespace/${v}/${type}`)}>
<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]">{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>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportCSV} title="导出CSV">
<Download className="h-3 w-3 mr-1" />CSV
</Button>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportJSON} title="导出JSON">
<Download className="h-3 w-3 mr-1" />JSON
</Button>
</div>
</div>
<div className="flex items-center gap-1 bg-slate-900/50 border border-slate-800 rounded-lg p-1 overflow-x-auto">
{sidebarResources.map((r) => (
<Link key={r.type} to={`/kubernetes/${clusterCode}/namespace/${namespace}/${r.type}`}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md transition-colors whitespace-nowrap ${r.type === type ? 'bg-blue-600/20 text-blue-400' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'}`}>
<r.icon className="h-3 w-3" />{r.label}
</Link>
))}
</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>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<Table>
<TableHeader><TableRow className="border-slate-800 hover:bg-transparent">{config.columns.map((col) => <TableHead key={col} className="text-slate-400">{col}</TableHead>)}</TableRow></TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
) : filtered.length === 0 ? (
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-slate-500 py-12"><config.icon className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 {config.title}</TableCell></TableRow>
) : (
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml }))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,152 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
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 { Cpu, HardDrive, RefreshCw, ChevronRight, Activity } from 'lucide-react';
import { kubernetesApi } from '@/services/api';
export default function PodMetricsPage() {
const { clusterCode } = useParams<{ clusterCode: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [metrics, setMetrics] = useState<any[]>([]);
const [clusters, setClusters] = useState<any[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNs, setSelectedNs] = useState('all');
const [activeCluster, setActiveCluster] = useState(clusterCode || '');
useEffect(() => {
kubernetesApi.getClusters().then((res: any) => {
const list = res?.list || [];
setClusters(list);
if (!activeCluster && list.length > 0) {
const code = list[0].code || String(list[0].id);
setActiveCluster(code);
navigate(`/kubernetes/${code}/metrics/pods`, { replace: true });
}
}).catch(() => {});
}, []);
useEffect(() => {
if (!activeCluster) return;
kubernetesApi.getNamespaces(activeCluster).then((res: any) => {
setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || []));
}).catch(() => {});
}, [activeCluster]);
const loadMetrics = useCallback(async () => {
if (!activeCluster) return;
setLoading(true);
try {
const ns = selectedNs === 'all' ? undefined : selectedNs;
const res: any = await kubernetesApi.getPodMetrics(activeCluster, ns);
setMetrics(Array.isArray(res) ? res : (res?.list || []));
} catch { setMetrics([]); } finally { setLoading(false); }
}, [activeCluster, selectedNs]);
useEffect(() => { loadMetrics(); }, [loadMetrics]);
// Flatten for table
const rows = useMemo(() =>
metrics.flatMap((pm: any) =>
(pm.containers || []).map((c: any) => ({
pod: pm.name,
namespace: pm.namespace,
container: c.name,
cpu: c.cpu || '0m',
memory: c.memory || '0Mi',
}))
), [metrics]);
const totalCPU = useMemo(() => rows.reduce((s, r) => s + (parseInt(r.cpu) || 0), 0), [rows]);
const totalMem = useMemo(() => {
let sum = 0;
rows.forEach(r => {
const v = r.memory;
if (v.endsWith('Mi')) sum += parseInt(v);
else if (v.endsWith('Ki')) sum += parseInt(v) / 1024;
else if (v.endsWith('Gi')) sum += parseInt(v) * 1024;
});
return sum;
}, [rows]);
return (
<div className="space-y-4">
<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">Pod 资源用量</span>
</div>
<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"><Activity className="h-5 w-5 text-blue-400" />Pod 资源用量</h2>
<div className="flex items-center gap-2">
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/metrics/pods`); }}>
<SelectTrigger className="w-44 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-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>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 text-xs" onClick={loadMetrics} disabled={loading}>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? 'animate-spin' : ''}`} />刷新
</Button>
</div>
</div>
{/* Summary cards */}
<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">Pod 数</p><p className="text-xl font-bold text-slate-200">{metrics.length}</p>
</CardContent></Card>
<Card className="bg-slate-900 border-slate-800"><CardContent className="p-3 text-center">
<p className="text-xs text-slate-500">总 CPU</p><p className="text-xl font-bold text-blue-400">{totalCPU}m</p>
</CardContent></Card>
<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-emerald-400">{totalMem.toFixed(0)}Mi</p>
</CardContent></Card>
</div>
<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">Pod</TableHead>
<TableHead className="text-slate-400">命名空间</TableHead>
<TableHead className="text-slate-400">容器</TableHead>
<TableHead className="text-slate-400"><Cpu className="h-3 w-3 inline mr-1" />CPU</TableHead>
<TableHead className="text-slate-400"><HardDrive className="h-3 w-3 inline mr-1" />Memory</TableHead>
</TableRow></TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-8">暂无数据{loading ? <span className="animate-spin inline-block ml-2">⏳</span> : ''}</TableCell></TableRow>
) : rows.map((r, i) => (
<TableRow key={`${r.pod}-${r.container}-${i}`} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200 text-sm">{r.pod}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">{r.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{r.container}</TableCell>
<TableCell className="text-sm text-blue-400">{r.cpu}</TableCell>
<TableCell className="text-sm text-emerald-400">{r.memory}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -24,6 +25,7 @@ import {
} from './topology-helpers';
export function TopologyView() {
const { clusterCode } = useParams<{ clusterCode?: string }>();
const _confirm = useConfirm();
const [clusters, setClusters] = useState<any[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
@@ -53,9 +55,17 @@ export function TopologyView() {
const res: any = await kubernetesApi.getClusters();
const list = res?.list || [];
setClusters(list);
if (list.length > 0 && !selectedClusterId) setSelectedClusterId(String(list[0].id));
// Auto-select from URL param (clusterCode) if provided, otherwise first cluster
if (!selectedClusterId) {
if (clusterCode) {
const matched = list.find((c: any) => (c.code || String(c.id)) === clusterCode);
if (matched) setSelectedClusterId(String(matched.id));
} else if (list.length > 0) {
setSelectedClusterId(String(list[0].id));
}
}
} catch { setClusters([]); }
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [clusterCode]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadClusters(); }, [loadClusters]);
@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react';
import { X, Copy, Check, Save, RefreshCw, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/services/api';
import { useUserStore } from '@/store';
interface YamlViewerProps {
clusterCode: string;
namespace: string;
resource: string; // e.g. "deployments", "services"
name: string;
onClose: () => void;
onSaved?: () => void;
}
export function YamlViewer({ clusterCode, namespace, resource, name, onClose, onSaved }: YamlViewerProps) {
const { toast } = useToast();
const [yaml, setYaml] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(false);
const [copied, setCopied] = useState(false);
const loadYaml = async () => {
setLoading(true);
try {
const tk = useUserStore.getState().token || '';
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`;
const resp = await fetch(url, { headers: { Authorization: `Bearer ${tk}` } });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const text = await resp.text();
setYaml(text);
} catch (e: any) {
toast({ title: '获取YAML失败', description: e.message, variant: 'destructive' });
setYaml(`# Error: ${e.message}`);
} finally { setLoading(false); }
};
const saveYaml = async () => {
setSaving(true);
try {
const tk = useUserStore.getState().token || '';
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/${resource}/${name}/yaml`;
const resp = await fetch(url, {
method: 'PUT',
headers: { Authorization: `Bearer ${tk}`, 'Content-Type': 'application/yaml' },
body: yaml,
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
toast({ title: 'YAML已应用', description: `${resource}/${name} 更新成功` });
setEditing(false);
onSaved?.();
} catch (e: any) {
toast({ title: '保存失败', description: e.message, variant: 'destructive' });
} finally { setSaving(false); }
};
const copyYaml = async () => {
await navigator.clipboard.writeText(yaml);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
useEffect(() => { loadYaml(); }, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col shadow-2xl" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700">
<div>
<h2 className="text-lg font-bold text-slate-100">{resource}/{name}</h2>
<p className="text-xs text-slate-500">{namespace} · {clusterCode}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={copyYaml}>
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
{copied ? '已复制' : '复制'}
</Button>
{!editing ? (
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => setEditing(true)}>
编辑
</Button>
) : (
<>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => { setEditing(false); loadYaml(); }}>
取消
</Button>
<Button size="sm" className="h-8 bg-blue-600 hover:bg-blue-700" onClick={saveYaml} disabled={saving}>
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
保存
</Button>
</>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-full"><Loader2 className="h-6 w-6 animate-spin text-slate-500" /></div>
) : editing ? (
<textarea
value={yaml}
onChange={e => setYaml(e.target.value)}
className="w-full h-full min-h-[500px] bg-slate-950 text-emerald-400 font-mono text-xs p-4 rounded border border-slate-700 focus:outline-none focus:border-blue-500 resize-none"
spellCheck={false}
/>
) : (
<pre className="text-emerald-400 font-mono text-xs whitespace-pre overflow-x-auto">{yaml}</pre>
)}
</div>
</div>
</div>
);
}