Files
aiops/frontend/src/components/kubernetes/NamespaceResourcePage.tsx
T
attacker ad9acea1e5 refactor(i18n): U33 K8s 资源编辑器 + Pod 日志/Metrics 模板字面量中文清零
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 测试通过
2026-07-12 16:51:57 +08:00

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>
);
}