Files
aiops/frontend/src/components/kubernetes/NamespaceResourcePage.tsx
T

565 lines
32 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.
/* eslint-disable @typescript-eslint/no-explicit-any */
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, Plus, Upload,
} from 'lucide-react';
import { YamlViewer } from './YamlViewer';
import { ResourceVisualEditor } from './ResourceVisualEditor';
import { YamlImportDialog } from './YamlImportDialog';
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';
// 支持可视化编辑/新建的资源类型 // Logan.Li
const VISUAL_RESOURCE_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets', 'pvcs'];
/** Deployment 列表:新建 + YAML 导入,不做批量导出 // Logan.Li */
const DEPLOYMENT_LIST_TYPES: ResourceType[] = ['deployments'];
/** 支持命名空间 YAML 批量导出(对齐集群导出)// Logan.Li */
const EXPORT_YAML_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets'];
/** 列表页保留 CSV/JSON 导出的配置类资源 // Logan.Li */
const BULK_EXPORT_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets', 'pvcs'];
const VisualActions = ({ name, opts }: { name: string; opts: { onYaml?: (n: string) => void; onEdit?: (n: string) => void } }) => (
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300" title="可视化编辑"
onClick={() => opts.onEdit?.(name)}><Pencil className="h-3.5 w-3.5 mr-1" />编辑</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-slate-400 hover:text-slate-300" title="YAML"
onClick={() => opts.onYaml?.(name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</div>
);
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>
</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-0.5">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-emerald-400" title="终端"
onClick={() => (opts as Record<string, any>).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-amber-400" title="文件"
onClick={() => (opts as Record<string, any>).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 text-blue-400" title="YAML"
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></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 onClick={(e) => e.stopPropagation()}><VisualActions name={s.name} opts={opts} /></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 onClick={(e) => e.stopPropagation()}><VisualActions name={i.name} opts={opts} /></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 onClick={(e) => e.stopPropagation()}><VisualActions name={c.name} opts={opts} /></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 onClick={(e) => e.stopPropagation()}><VisualActions name={s.name} opts={opts} /></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 onClick={(e) => e.stopPropagation()}><VisualActions name={p.name} opts={opts} /></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 } = 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 [visualItem, setVisualItem] = useState<{ resource: string; name: string; isCreate?: boolean } | null>(null);
const [showYamlImport, setShowYamlImport] = useState(false);
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 });
// 网络/配置/存储走可视化编辑器,YAML 作为高级入口 // Logan.Li
const handleEdit = (name: string) => {
if (VISUAL_RESOURCE_TYPES.includes(type)) {
setVisualItem({ resource: type, name });
} else {
setYamlItem({ resource: type, name, editMode: true });
}
};
const handleCreate = () => {
if (type === 'deployments') {
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/edit/Deployment/_create`);
return;
}
setVisualItem({ resource: type, name: '', isCreate: true });
};
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered);
const handleExportYaml = async () => {
if (!clusterCode || !namespace) return;
try {
await kubernetesApi.exportNamespaceResources(clusterCode, namespace);
toast({ title: '导出完成', description: `${namespace} 资源 YAML 已开始下载` });
} catch (e) {
toast({ title: '导出失败', description: (e as Error)?.message || String(e), variant: 'destructive' });
}
};
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}
autoEdit={yamlItem.editMode === true}
/>
)}
{showYamlImport && (
<YamlImportDialog
clusterCode={clusterCode || ''}
onClose={() => setShowYamlImport(false)}
onApplied={loadData}
/>
)}
{visualItem && (
<ResourceVisualEditor
clusterCode={clusterCode || ''}
namespace={namespace || ''}
resource={visualItem.resource}
name={visualItem.name}
isCreate={visualItem.isCreate}
onClose={() => setVisualItem(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>
{BULK_EXPORT_TYPES.includes(type) && (
<>
<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>
</>
)}
{EXPORT_YAML_TYPES.includes(type) && (
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportYaml} title="导出命名空间 YAML(tar.gz)">
<Download className="h-3 w-3 mr-1" />导出YAML
</Button>
)}
{(VISUAL_RESOURCE_TYPES.includes(type) || DEPLOYMENT_LIST_TYPES.includes(type)) && (
<>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={() => setShowYamlImport(true)}>
<Upload className="h-3 w-3 mr-1" />导入
</Button>
<Button size="sm" className="h-9 text-xs bg-blue-600 hover:bg-blue-700" onClick={handleCreate}>
<Plus className="h-3 w-3 mr-1" />新建
</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>
);
}