565 lines
32 KiB
TypeScript
565 lines
32 KiB
TypeScript
/* 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>
|
||
);
|
||
}
|