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:
1 parent
cac1414917
commit
a3cd0d3910
29 files changed
+2954
-180
No files matched your search
@@ -98,6 +98,7 @@ export function Login() {
|
||||
try {
|
||||
const result = await userApi.login(username, password);
|
||||
useUserStore.getState().login(result.user, result.token);
|
||||
localStorage.setItem('token', result.token); // backward compat
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '登录失败,请检查用户名和密码';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,37 +3,12 @@ import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useThemeStore, useMenuStore } from '@/store';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Cloud,
|
||||
Rocket,
|
||||
Bell,
|
||||
CheckSquare,
|
||||
PlayCircle,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
Cpu,
|
||||
Database,
|
||||
FileCode,
|
||||
Shield,
|
||||
Users,
|
||||
Menu,
|
||||
X,
|
||||
Network,
|
||||
CloudCog,
|
||||
Globe,
|
||||
MapPin,
|
||||
Key,
|
||||
Brain,
|
||||
FileTerminal,
|
||||
Activity,
|
||||
BarChart3,
|
||||
GitBranch,
|
||||
Monitor,
|
||||
Bot,
|
||||
AlertTriangle,
|
||||
LayoutDashboard, Server, Cloud, Rocket, Bell, Monitor,
|
||||
CheckSquare, PlayCircle, Settings, ChevronDown, ChevronRight,
|
||||
Terminal, Cpu, Database, FileCode, Shield, Users,
|
||||
Menu, Network, CloudCog, Globe, MapPin, Key,
|
||||
Brain, FileTerminal, Activity, BarChart3, GitBranch,
|
||||
Bot, AlertTriangle, Container, Boxes, Clock,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -49,9 +24,7 @@ interface MenuItem {
|
||||
const menuItems: MenuItem[] = [
|
||||
{ id: 'dashboard', name: '仪表盘', icon: LayoutDashboard, path: '/' },
|
||||
{
|
||||
id: 'assets',
|
||||
name: '资产管理',
|
||||
icon: Server,
|
||||
id: 'assets', name: '资产管理', icon: Server,
|
||||
children: [
|
||||
{ id: 'servers', name: '服务器管理', icon: Database, path: '/servers' },
|
||||
{ id: 'cloud-accounts', name: '云账号管理', icon: Key, path: '/cloud-accounts' },
|
||||
@@ -62,29 +35,50 @@ const menuItems: MenuItem[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kubernetes',
|
||||
name: 'Kubernetes',
|
||||
icon: Cloud,
|
||||
id: 'kubernetes', name: 'Kubernetes', icon: Cloud,
|
||||
children: [
|
||||
{ id: 'clusters', name: '集群列表', icon: Cloud, path: '/clusters' },
|
||||
{ id: 'nodes', name: '节点管理', icon: Cpu, path: '/nodes' },
|
||||
{ id: 'workloads', name: '工作负载', icon: Database, path: '/workloads' },
|
||||
{ id: 'topology', name: '架构拓扑', icon: Network, path: '/topology' },
|
||||
{ id: 'k8s-clusters', name: '集群列表', icon: Cloud, path: '/kubernetes' },
|
||||
{ id: 'k8s-nodes', name: '节点管理', icon: Cpu, path: '/nodes' },
|
||||
{
|
||||
id: 'k8s-workload', name: '工作负载', icon: Boxes,
|
||||
children: [
|
||||
{ id: 'k8s-deployments', name: 'Deployments', icon: Boxes, path: '/workloads/deployments' },
|
||||
{ id: 'k8s-statefulsets', name: 'StatefulSets', icon: Boxes, path: '/workloads/statefulsets' },
|
||||
{ id: 'k8s-daemonsets', name: 'DaemonSets', icon: Boxes, path: '/workloads/daemonsets' },
|
||||
{ id: 'k8s-cronjobs', name: 'CronJobs', icon: Clock, path: '/workloads/cronjobs' },
|
||||
{ id: 'k8s-pods', name: 'Pods', icon: Container, path: '/workloads/pods' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'k8s-network', name: '网络', icon: Network,
|
||||
children: [
|
||||
{ id: 'k8s-services', name: 'Services', icon: Network, path: '/workloads/services' },
|
||||
{ id: 'k8s-ingresses', name: 'Ingresses', icon: Globe, path: '/workloads/ingresses' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'k8s-config', name: '配置中心', icon: Settings,
|
||||
children: [
|
||||
{ id: 'k8s-configmaps', name: 'ConfigMaps', icon: FileCode, path: '/workloads/configmaps' },
|
||||
{ id: 'k8s-secrets', name: 'Secrets', icon: Shield, path: '/workloads/secrets' },
|
||||
],
|
||||
},
|
||||
{ id: 'k8s-storage', name: '存储', icon: Database, path: '/workloads/pvcs' },
|
||||
{ id: 'k8s-metrics', name: '资源用量', icon: Activity, path: '/workloads/metrics' },
|
||||
{ id: 'k8s-events', name: '事件', icon: Bell, path: '/workloads/events' },
|
||||
{ id: 'k8s-capacity', name: '容量分析', icon: BarChart3, path: '/workloads/capacity' },
|
||||
{ id: 'k8s-topology', name: '架构拓扑', icon: Network, path: '/topology' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'deploy',
|
||||
name: '应用发布',
|
||||
icon: Rocket,
|
||||
id: 'deploy', name: '应用发布', icon: Rocket,
|
||||
children: [
|
||||
{ id: 'pipelines', name: '流水线管理', icon: GitBranch, path: '/pipelines' },
|
||||
{ id: 'releases', name: '发布管理', icon: Rocket, path: '/releases' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'monitor',
|
||||
name: '监控中心',
|
||||
icon: Bell,
|
||||
id: 'monitor', name: '监控中心', icon: Bell,
|
||||
children: [
|
||||
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards' },
|
||||
{ id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center' },
|
||||
@@ -94,9 +88,7 @@ const menuItems: MenuItem[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'automation',
|
||||
name: '自动化运维',
|
||||
icon: PlayCircle,
|
||||
id: 'automation', name: '自动化运维', icon: PlayCircle,
|
||||
children: [
|
||||
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' },
|
||||
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' },
|
||||
@@ -104,9 +96,7 @@ const menuItems: MenuItem[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aiops',
|
||||
name: 'AIOps智能',
|
||||
icon: Brain,
|
||||
id: 'aiops', name: 'AIOps智能', icon: Brain,
|
||||
children: [
|
||||
{ id: 'ai-assistant', name: '智能助手', icon: Bot, path: '/ai-assistant' },
|
||||
{ id: 'ai-alert-analyzer', name: '告警分析', icon: AlertTriangle, path: '/ai-alert-analyzer' },
|
||||
@@ -115,9 +105,7 @@ const menuItems: MenuItem[] = [
|
||||
},
|
||||
{ id: 'approval', name: '资源申请', icon: CheckSquare, path: '/approval' },
|
||||
{
|
||||
id: 'system',
|
||||
name: '系统管理',
|
||||
icon: Settings,
|
||||
id: 'system', name: '系统管理', icon: Settings,
|
||||
children: [
|
||||
{ id: 'users', name: '用户管理', icon: Users, path: '/users' },
|
||||
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
|
||||
@@ -135,7 +123,11 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
|
||||
const { sidebarCollapsed } = useThemeStore();
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isOpen = openMenus.includes(item.id);
|
||||
const isActive = item.path === location.pathname || activeMenu === item.id;
|
||||
const isActive = item.path
|
||||
? location.pathname === item.path || location.pathname.startsWith(item.path + '/')
|
||||
: (item.id === 'kubernetes' && location.pathname.startsWith('/kubernetes')) ||
|
||||
(item.id === 'k8s-workload' && location.pathname.startsWith('/workloads')) ||
|
||||
activeMenu === item.id;
|
||||
|
||||
const handleClick = () => {
|
||||
if (hasChildren) {
|
||||
@@ -151,38 +143,22 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
|
||||
<Button
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className={cn(
|
||||
'w-10 h-10 mx-auto mb-1',
|
||||
isActive && 'bg-blue-600/20 text-blue-400'
|
||||
)}
|
||||
className={cn('w-10 h-10 mx-auto mb-1', isActive && 'bg-blue-600/20 text-blue-400')}
|
||||
asChild={!hasChildren}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<span onClick={handleClick}>
|
||||
<item.icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span onClick={handleClick}><item.icon className="h-5 w-5" /></span>
|
||||
) : (
|
||||
<NavLink to={item.path || '#'} onClick={handleClick}>
|
||||
<item.icon className="h-5 w-5" />
|
||||
</NavLink>
|
||||
<NavLink to={item.path || '#'} onClick={handleClick}><item.icon className="h-5 w-5" /></NavLink>
|
||||
)}
|
||||
</Button>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute left-full top-1/2 -translate-y-1/2 ml-2 px-3 py-1.5 bg-slate-800 text-slate-100 text-sm rounded-md opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 border border-slate-700">
|
||||
{item.name}
|
||||
{hasChildren && (
|
||||
<div className="absolute left-0 top-0 mt-2 ml-2 w-48 bg-slate-800 rounded-md border border-slate-700 shadow-lg py-1">
|
||||
{item.children?.map((child) => (
|
||||
<NavLink
|
||||
key={child.id}
|
||||
to={child.path || '#'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'block px-4 py-2 text-sm hover:bg-slate-700 transition-colors',
|
||||
isActive ? 'text-blue-400 bg-blue-600/10' : 'text-slate-300'
|
||||
)
|
||||
}
|
||||
>
|
||||
<NavLink key={child.id} to={child.path || '#'}
|
||||
className={({ isActive }) => cn('block px-4 py-2 text-sm hover:bg-slate-700 transition-colors', isActive ? 'text-blue-400 bg-blue-600/10' : 'text-slate-300')}>
|
||||
{child.name}
|
||||
</NavLink>
|
||||
))}
|
||||
@@ -194,46 +170,24 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('mb-0.5', depth > 0 && 'ml-4')}>
|
||||
<div className={cn('mb-0.5', depth > 0 && 'ml-3')}>
|
||||
{hasChildren ? (
|
||||
<>
|
||||
<Button
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
className={cn(
|
||||
'w-full justify-between h-9 px-3 text-sm font-normal',
|
||||
isActive ? 'bg-blue-600/20 text-blue-400 hover:bg-blue-600/30' : 'text-slate-300 hover:text-slate-100 hover:bg-slate-800'
|
||||
)}
|
||||
className={cn('w-full justify-between h-8 px-2.5 text-xs font-normal', isActive ? 'bg-blue-600/20 text-blue-400 hover:bg-blue-600/30' : 'text-slate-300 hover:text-slate-100 hover:bg-slate-800')}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
<span>{item.name}</span>
|
||||
</span>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-slate-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-slate-500" />
|
||||
)}
|
||||
{isOpen ? <ChevronDown className="h-3 w-3 text-slate-500" /> : <ChevronRight className="h-3 w-3 text-slate-500" />}
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{item.children?.map((child) => (
|
||||
<NavLink
|
||||
key={child.id}
|
||||
to={child.path || '#'}
|
||||
onClick={() => setActiveMenu(child.id)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 px-3 py-2 text-sm rounded-md transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800'
|
||||
)
|
||||
}
|
||||
>
|
||||
<child.icon className="h-3.5 w-3.5" />
|
||||
<span>{child.name}</span>
|
||||
</NavLink>
|
||||
<MenuItemComponent key={child.id} item={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -243,15 +197,11 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
|
||||
to={item.path || '#'}
|
||||
onClick={handleClick}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 px-3 py-2 text-sm rounded-md transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'text-slate-300 hover:text-slate-100 hover:bg-slate-800'
|
||||
)
|
||||
cn('flex items-center gap-1.5 h-8 px-2.5 text-xs rounded-md transition-colors',
|
||||
isActive ? 'bg-blue-600/20 text-blue-400' : 'text-slate-400 hover:text-slate-100 hover:bg-slate-800')
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<item.icon className="h-3.5 w-3.5" />
|
||||
<span>{item.name}</span>
|
||||
</NavLink>
|
||||
)}
|
||||
@@ -261,15 +211,8 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
|
||||
|
||||
export function Sidebar() {
|
||||
const { sidebarCollapsed, toggleSidebar } = useThemeStore();
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed left-0 top-0 z-40 h-screen bg-slate-900 border-r border-slate-800 transition-all duration-300',
|
||||
sidebarCollapsed ? 'w-16' : 'w-64'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<aside className={cn('fixed left-0 top-0 z-40 h-screen bg-slate-900 border-r border-slate-800 transition-all duration-300', sidebarCollapsed ? 'w-16' : 'w-64')}>
|
||||
<div className="flex items-center justify-between h-14 px-4 border-b border-slate-800">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -285,23 +228,14 @@ export function Sidebar() {
|
||||
</div>
|
||||
)}
|
||||
{!sidebarCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100" onClick={toggleSidebar}>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<ScrollArea className="h-[calc(100vh-3.5rem)]">
|
||||
<div className={cn('p-2', sidebarCollapsed && 'px-1')}>
|
||||
{menuItems.map((item) => (
|
||||
<MenuItemComponent key={item.id} item={item} />
|
||||
))}
|
||||
<div className={cn('p-1.5', sidebarCollapsed && 'px-1')}>
|
||||
{menuItems.map((item) => <MenuItemComponent key={item.id} item={item} />)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
@@ -661,7 +661,7 @@ export default function PrometheusDatasourceManager() {
|
||||
<SelectValue placeholder="Choose" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">无</SelectItem>
|
||||
<SelectItem value="__none">无</SelectItem>
|
||||
{sources.filter(s => s.id !== editingId).map(s => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -208,7 +208,7 @@ export function ServerForm({
|
||||
<SelectValue placeholder="选择已有凭证..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="">手动输入(不关联凭证)</SelectItem>
|
||||
<SelectItem value="__manual">手动输入(不关联凭证)</SelectItem>
|
||||
{credentials.map((cred) => (
|
||||
<SelectItem key={cred.id} value={String(cred.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
Reference in new issue
Block a user