Files
aiops/frontend/src/components/kubernetes/NamespaceResourcePage.tsx
T
Logan.Li 339b24a579 优化 K8s 页面布局与 local 模式部署收尾
P0-P1:
- Dockerfile.embed 启用 CGO 支持 SQLite+redka
- K8s scale>1 时警告 local 模式数据不共享

K8s UI:
- 移除侧边栏资源用量/容量分析及资源页横向 Tab 栏
- 修复事件路由(/workloads/events)与 Deployment 事件过滤
- 网络/配置/存储统一 YamlViewer 编辑,加大编辑弹窗
2026-06-26 13:36:27 +08:00

505 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Pencil, Terminal, HardDrive,
} from 'lucide-react';
import { YamlViewer } from './YamlViewer';
import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
type ResourceType =
| 'deployments' | 'pods' | 'services' | 'ingresses'
| 'configmaps' | 'secrets' | 'hpas' | 'cronjobs'
| 'pvcs' | 'statefulsets' | 'daemonsets'
| 'serviceaccounts' | 'roles' | 'rolebindings';
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; onEdit?: (name: string) => void; onTerminal?: ( item: any) => void; onFileBrowser?: ( item: any) => 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: ['名称', '命名空间', '状态', '节点', 'CPU', '内存', '重启', '镜像', '操作'],
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="text-sm text-cyan-400">{p.cpu_usage_m || '-'}</TableCell>
<TableCell className="text-sm text-amber-400">{p.mem_usage_mi || '-'}</TableCell>
<TableCell className={(p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500')}>{p.restart_count || 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10" title="终端"
onClick={() => (opts as Record<string, any>).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5 mr-1" />终端</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10" title="文件浏览器"
onClick={() => (opts as Record<string, any>).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" title="查看YAML"
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</div>
</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="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
onClick={() => opts.onEdit?.(s.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</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="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
onClick={() => opts.onEdit?.(i.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</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="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
onClick={() => opts.onEdit?.(c.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</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="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
onClick={() => opts.onEdit?.(s.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</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>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300" title="YAML 编辑"
onClick={() => opts.onEdit?.(p.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</TableCell>
</TableRow>
),
},
statefulsets: {
title: 'StatefulSets', subtitle: '有状态应用', icon: Boxes,
columns: ['名称', '命名空间', '副本', '就绪', '镜像'],
renderRow: (s, opts) => (
<TableRow key={s.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer" onClick={() => opts.onDetail?.(s)}>
<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, 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 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>
),
},
serviceaccounts: {
title: 'ServiceAccounts', subtitle: '服务账号(RBAC 身份标识)', icon: Shield,
columns: ['名称', '命名空间', 'Secret数', '镜像拉取凭证', '创建时间', '操作'],
renderRow: (sa, opts) => (
<TableRow key={sa.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{sa.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{sa.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{sa.secret_count ?? 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">
{sa.image_pull_secrets && sa.image_pull_secrets.length > 0 ? sa.image_pull_secrets.join(', ') : '-'}
</TableCell>
<TableCell className="text-xs text-slate-500">{sa.age || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML"
onClick={() => opts.onYaml?.(sa.name)}><FileCode className="h-3.5 w-3.5" /></Button>
</TableCell>
</TableRow>
),
},
roles: {
title: 'Roles', subtitle: '命名空间级角色权限(RBAC)', icon: Shield,
columns: ['名称', '命名空间', '规则数', '权限范围', '创建时间', '操作'],
renderRow: (r, opts) => {
const verbs = r.verbs || [];
const resources = r.resources || [];
const verbsStr = verbs.length > 4 ? verbs.slice(0, 4).join(', ') + ` +${verbs.length - 4}` : verbs.join(', ');
const resStr = resources.length > 4 ? resources.slice(0, 4).join(', ') + ` +${resources.length - 4}` : resources.join(', ');
return (
<TableRow key={r.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{r.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{r.namespace}</Badge></TableCell>
<TableCell className="text-sm text-slate-400">{r.rule_count ?? 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[280px]">
<div className="flex flex-col gap-0.5">
<span className="text-cyan-400 truncate" title={verbs.join(', ')}>verbs: {verbsStr || '-'}</span>
<span className="text-amber-400 truncate" title={resources.join(', ')}>resources: {resStr || '-'}</span>
</div>
</TableCell>
<TableCell className="text-xs text-slate-500">{r.age || '-'}</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?.(r.name)}><FileCode className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑"
onClick={() => opts.onEdit?.(r.name)}><Pencil className="h-3.5 w-3.5" /></Button>
</div>
</TableCell>
</TableRow>
);
},
},
rolebindings: {
title: 'RoleBindings', subtitle: '角色绑定关系(RBAC 授权)', icon: Shield,
columns: ['名称', '命名空间', '角色引用', '绑定主体', '创建时间', '操作'],
renderRow: (rb, opts) => {
const subjects = rb.subjects || [];
const subjStr = subjects.length > 3 ? subjects.slice(0, 3).join(', ') + ` +${subjects.length - 3}` : subjects.join(', ');
return (
<TableRow key={rb.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{rb.name}</TableCell>
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{rb.namespace}</Badge></TableCell>
<TableCell>
<Badge className="bg-purple-500/20 text-purple-400 border-purple-500/30">
{rb.role_ref_kind || '-'}/{rb.role_ref_name || '-'}
</Badge>
</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate" title={subjects.join(', ')}>
{subjStr || '-'}
</TableCell>
<TableCell className="text-xs text-slate-500">{rb.age || '-'}</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?.(rb.name)}><FileCode className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-emerald-400 hover:text-emerald-300" title="编辑"
onClick={() => opts.onEdit?.(rb.name)}><Pencil className="h-3.5 w-3.5" /></Button>
</div>
</TableCell>
</TableRow>
);
},
},
};
export default function NamespaceResourcePage() {
const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>();
const location = useLocation();
const navigate = useNavigate();
const { toast: _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; editMode?: boolean} | null>(null);
const [terminalPod, setTerminalPod] = useState<any>(null);
const [fileBrowserPod, setFileBrowserPod] = useState<any>(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: Record<string, 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;
case 'serviceaccounts': res = await kubernetesApi.getServiceAccounts(clusterCode, namespace).catch(() => []); break;
case 'roles': res = await kubernetesApi.getRoles(clusterCode, namespace).catch(() => []); break;
case 'rolebindings': res = await kubernetesApi.getRoleBindings(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') {
// Kuboard 风格双模式路由(默认 view 模式)
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/Deployment/${item.name}`);
} else if (type === 'statefulsets') {
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/StatefulSet/${item.name}`);
} else if (type === 'daemonsets') {
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/DaemonSet/${item.name}`);
}
};
const handleYaml = (name: string) => setYamlItem({ resource: type, name });
// 网络/配置/存储统一走 YamlViewer 编辑(清理只读字段 + CodeMirror)// Logan.Li
const handleEdit = (name: string) => setYamlItem({ resource: type, name, editMode: true });
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered);
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}
cleanBeforeEdit={true}
autoEdit={yamlItem.editMode === true}
/>
)}
<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-3 flex-wrap">
<div className="relative w-56 flex-1 min-w-[200px] max-w-xs">
<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>
</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, onEdit: handleEdit, onTerminal: ( p: any) => setTerminalPod(p), onFileBrowser: ( p: any) => setFileBrowserPod(p) }))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* ============ Pod 终端 ============ */}
<PodExecTerminal
open={!!terminalPod}
onOpenChange={(open) => { if (!open) setTerminalPod(null); }}
clusterId={clusters[0]?.id || clusters[0]?.code || clusterCode || ''}
namespace={namespace || ''}
podName={terminalPod?.name || ''}
containers={(terminalPod?.containers || []).map(( c: any) => c.name).filter(Boolean)}
/>
{/* ============ Pod 文件浏览器 ============ */}
<PodFileBrowser
open={!!fileBrowserPod}
onOpenChange={(open) => { if (!open) setFileBrowserPod(null); }}
clusterCode={clusterCode || ''}
namespace={namespace || ''}
podName={fileBrowserPod?.name || ''}
containers={(fileBrowserPod?.containers || []).map(( c: any) => c.name).filter(Boolean)}
/>
</div>
);
}