resource-visual-editors/ 5 文件 (ConfigMap/Secret/Service/Ingress/PvcEditor): - 保存按钮文案 (保存配置/密钥/Service/Ingress/PVC) - IngressEditor: (可选)/手工输入/帮助文本/常见示例/手动输入/继续手动输入 - PvcEditor: 访问模式描述 (单节点读写/多节点只读/多节点读写) pod-logs-viewer/ 3 文件: - ControlBar: 导出/刷新/实时/停止按钮 - LogContentArea: 加载中… 占位 - LogsHeader: 日志标题模板 PodLogsViewer: 6 处错误消息 + DialogTitle/Description PodMetricsPage: subtitle 模板字面量 + 所有命名空间 PodExecTerminal: 连接错误消息 17 处 toast description 模板字面量中文→i18n.t() 插值: - DeploymentEditAdvanced/Containers/Storage: 更新已提交 - DeploymentView: 回滚/伸缩 - DeploymentDetail: 伸缩/镜像更新 - DeploymentEditService: 资源删除 - ResourceVisualEditor/NamespaceResourcePage: 资源更新/YAML下载 - ClusterOverviewPage: 集群未找到 - DeploymentEditContainers: 容器名称/镜像为空 5 处 confirm description 中文→i18n.t() 插值: - DeploymentView/EditService/Detail/WorkloadViewPage/MiddlewareMonitor/SiteMonitor/HealthProbeSection 5 处 subtitle 模板字面量中文→i18n.t(): - PodMetricsPage/CapacityPage/EventsPage/KubernetesNodes/ClusterOverviewPage 新增 k8s.rve 14键 + k8s.toast 18键 + k8s.podMetrics 4键 + k8s.podLogs 13键 = 49 翻译键 zh/en 7481→7524 完全对齐 tsc -b=0, npm build✓ (17.76s), ESLint errors=0/warnings=43/any=0, go build=0, 17/17 测试通过
301 lines
15 KiB
TypeScript
301 lines
15 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
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, Download, Upload, Plus } from 'lucide-react';
|
|
import { YamlViewer } from './YamlViewer';
|
|
import { ResourceVisualEditor } from './ResourceVisualEditor';
|
|
import { YamlImportDialog } from './YamlImportDialog';
|
|
import { PodExecTerminal } from './PodExecTerminal';
|
|
import { PodFileBrowser } from './PodFileBrowser';
|
|
import { PodDetailDialog } from './PodDetailDialog';
|
|
import {
|
|
RESOURCE_CONFIG, VISUAL_RESOURCE_TYPES, DEPLOYMENT_LIST_TYPES,
|
|
EXPORT_YAML_TYPES, BULK_EXPORT_TYPES,
|
|
type ResourceType,
|
|
} from './namespace-resource-config';
|
|
import { kubernetesApi } from '@/api';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
|
import { PageShell } from '@/components/layout/PageShell';
|
|
import { ContentPanel } from '@/components/layout/ContentPanel';
|
|
import i18n from '@/i18n';
|
|
|
|
export default function NamespaceResourcePage() {
|
|
const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
const confirm = useConfirm();
|
|
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);
|
|
const [podDetail, setPodDetail] = 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([]));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
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([]));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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') {
|
|
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: i18n.t('common.exportComplete'), description: i18n.t('k8s.toast.yamlDownloadStarted', { namespace }) });
|
|
} catch (e) {
|
|
toast({ title: i18n.t('kubernetes.msg.exportFailed'), description: (e as Error)?.message || String(e), variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
// Pod 删除 // Logan.Li
|
|
const handleDeletePod = async (p: any) => {
|
|
const ok = await confirm({
|
|
title: i18n.t('kubernetes.pod.deletePod'),
|
|
description: i18n.t('k8s.namespaceResource.delete1') + (p.name || '') + i18n.t('k8s.namespaceResource.actions'),
|
|
confirmText: i18n.t('kubernetes.storage.delete2'),
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await kubernetesApi.deletePod(clusterCode || '', namespace || '', p.name);
|
|
toast({ title: i18n.t('kubernetes.msg.podDeleted') });
|
|
loadData();
|
|
} catch (e) {
|
|
toast({ title: i18n.t('kubernetes.msg.deleteFailed2'), description: (e as any)?.message || String(e), variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
if (!config) return <div className="text-[var(--dg-text-secondary)] p-8 text-center">未知资源类型: {type}</div>;
|
|
|
|
const searchToolbar = (
|
|
<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 dg-text-muted" />
|
|
<Input placeholder={i18n.t('kubernetes.namespaceResource.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 dg-input" />
|
|
</div>
|
|
);
|
|
|
|
const ResourceIcon = config.icon;
|
|
const subtitleParts = [clusterCode, namespace].filter(Boolean);
|
|
const pageSubtitle = subtitleParts.length > 0
|
|
? `${subtitleParts.join(' / ')} · ${config.subtitle}`
|
|
: config.subtitle;
|
|
|
|
return (
|
|
<PageShell
|
|
variant="list"
|
|
title={config.title}
|
|
subtitle={pageSubtitle}
|
|
icon={<ResourceIcon className="h-5 w-5" />}
|
|
actions={(
|
|
<>
|
|
<Select value={clusterCode || ''} onValueChange={(v) => navigate(`/kubernetes/${v}/namespace/${namespace || 'default'}/${type}`)}>
|
|
<SelectTrigger className="w-48 h-9 dg-input"><SelectValue placeholder={i18n.t('kubernetes.topology.selectCluster')} /></SelectTrigger>
|
|
<SelectContent>{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 dg-input"><SelectValue placeholder={i18n.t('kubernetes.namespace.title')} /></SelectTrigger>
|
|
<SelectContent className="max-h-[300px]">{namespaces.map((ns) => <SelectItem key={ns} value={ns}>{ns}</SelectItem>)}</SelectContent>
|
|
</Select>
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadData} disabled={loading}>
|
|
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
|
刷新
|
|
</Button>
|
|
{BULK_EXPORT_TYPES.includes(type) && (
|
|
<>
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary text-xs" onClick={handleExportCSV} title={i18n.t('k8s.nsResourcePage.exportCsv')}>
|
|
<Download className="h-3 w-3 mr-1" />CSV
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary text-xs" onClick={handleExportJSON} title={i18n.t('k8s.nsResourcePage.exportJson')}>
|
|
<Download className="h-3 w-3 mr-1" />JSON
|
|
</Button>
|
|
</>
|
|
)}
|
|
{EXPORT_YAML_TYPES.includes(type) && (
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary text-xs" onClick={handleExportYaml} title={i18n.t('k8s.nsResourcePage.exportYaml')}>
|
|
<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="dg-btn-secondary text-xs" onClick={() => setShowYamlImport(true)}>
|
|
<Upload className="h-3 w-3 mr-1" />导入
|
|
</Button>
|
|
<Button size="sm" className="dg-btn-primary text-xs" onClick={handleCreate}>
|
|
<Plus className="h-3 w-3 mr-1" />新建
|
|
</Button>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
>
|
|
{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}
|
|
/>
|
|
)}
|
|
|
|
<ContentPanel toolbar={searchToolbar}>
|
|
<Table>
|
|
<TableHeader><TableRow className="border-[var(--dg-border)] hover:bg-transparent">{config.columns.map((col) => <TableHead key={col} className="text-[var(--dg-text-secondary)]">{col}</TableHead>)}</TableRow></TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-[var(--dg-text-muted)] py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" /> {i18n.t('monitor.logPanel.logResults.loading')} </TableCell></TableRow>
|
|
) : filtered.length === 0 ? (
|
|
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-[var(--dg-text-muted)] 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), onDeletePod: handleDeletePod, onPodDetail: (p: any) => setPodDetail(p) }))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</ContentPanel>
|
|
|
|
{/* ============ 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)}
|
|
/>
|
|
{/* ============ Pod 详情弹窗 ============ */}
|
|
<PodDetailDialog pod={podDetail} onClose={() => setPodDetail(null)} />
|
|
</PageShell>
|
|
);
|
|
}
|