Files
aiops/frontend/src/components/kubernetes/DeploymentEditStorage.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

351 lines
17 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.
// DeploymentEditStorage — 存储挂载 Tab(Kuboard 一卷一卡:定义卷 + 多容器挂载)// Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server, Container,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import { saveDeploymentManifest } from './deployment-manifest-save';
import { ResourceVisualEditor } from './ResourceVisualEditor';
import {
VolumeTypeFields,
extractResourceNames,
type NamespaceResourceLists,
} from './deployment-storage-fields';
import {
type VolumeForm,
type VolumeEntry,
type VolumeMountRow,
parseVolumeEntries,
applyStorageToManifest,
formToVolume,
volumeEntriesToStorage,
emptyVolumeEntry,
emptyMountRow,
} from './deployment-volume-utils';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
interface Props {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: JsonData | null;
onSaved: (updated?: JsonData) => void;
onCancel: () => void;
}
const VOLUME_TYPES: { key: VolumeForm['type']; label: string; icon: typeof HardDrive; color: string }[] = [
{ key: 'persistentVolumeClaim', label: i18n.t('k8s.nsResource.pvcDesc'), icon: Database, color: 'dg-text-green' },
{ key: 'emptyDir', label: i18n.t('k8s.editStorage.emptyDir'), icon: Disc, color: 'dg-text-secondary' },
{ key: 'hostPath', label: 'HostPath', icon: HardDrive, color: 'dg-text-yellow' },
{ key: 'nfs', label: 'NFS', icon: Server, color: 'dg-text-purple' },
{ key: 'configMap', label: i18n.t('k8s.nsResource.configMapDesc'), icon: File, color: 'dg-text-blue' },
{ key: 'secret', label: i18n.t('k8s.secret.data'), icon: Lock, color: 'text-rose-400' },
];
const SELECTABLE_TYPES = new Set(VOLUME_TYPES.map((t) => t.key));
type CreateResourceKind = 'configmaps' | 'secrets' | 'pvcs';
export function DeploymentEditStorage({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: Props) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest);
const [rawManifest, setRawManifest] = useState<JsonData>(manifest || {});
const [entries, setEntries] = useState<VolumeEntry[]>([]);
const [containers, setContainers] = useState<string[]>([]);
const [passthroughVolumes, setPassthroughVolumes] = useState<JsonData[]>([]);
const [resources, setResources] = useState<NamespaceResourceLists>({ configMaps: [], secrets: [], pvcs: [] });
const [resourcesLoading, setResourcesLoading] = useState(false);
const [createResource, setCreateResource] = useState<CreateResourceKind | null>(null);
const loadResources = useCallback(async () => {
if (!clusterCode || !namespace) return;
setResourcesLoading(true);
try {
const [cm, sec, pvc] = await Promise.all([
kubernetesApi.getNamespaceConfigMaps(clusterCode, namespace).catch(() => []),
kubernetesApi.getNamespaceSecrets(clusterCode, namespace).catch(() => []),
kubernetesApi.getPVCs(clusterCode, namespace).catch(() => []),
]);
setResources({
configMaps: extractResourceNames(cm),
secrets: extractResourceNames(sec),
pvcs: extractResourceNames(pvc),
});
} finally {
setResourcesLoading(false);
}
}, [clusterCode, namespace]);
useEffect(() => { loadResources(); }, [loadResources]);
useEffect(() => {
if (!manifest) { setLoading(false); return; }
setRawManifest(manifest);
const parsed = parseVolumeEntries(manifest);
setEntries(parsed.entries);
setContainers(parsed.containers);
setPassthroughVolumes(parsed.passthroughVolumes);
setLoading(false);
}, [manifest]);
const defaultContainer = containers.find((c) => c !== '(unnamed)') || containers[0] || '';
const updateVolume = (i: number, patch: Partial<VolumeForm>) =>
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, volume: { ...e.volume, ...patch } } : e)));
const updateMount = (entryIdx: number, mountIdx: number, patch: Partial<VolumeMountRow>) =>
setEntries((prev) => prev.map((e, i) => {
if (i !== entryIdx) return e;
return {
...e,
mounts: e.mounts.map((m, j) => (j === mountIdx ? { ...m, ...patch } : m)),
};
}));
const addMount = (entryIdx: number) =>
setEntries((prev) => prev.map((e, i) => {
if (i !== entryIdx) return e;
return { ...e, mounts: [...e.mounts, emptyMountRow(defaultContainer)] };
}));
const removeMount = (entryIdx: number, mountIdx: number) =>
setEntries((prev) => prev.map((e, i) => {
if (i !== entryIdx) return e;
const next = e.mounts.filter((_, j) => j !== mountIdx);
return { ...e, mounts: next.length > 0 ? next : [emptyMountRow(defaultContainer)] };
}));
const addEntry = () => setEntries((prev) => [...prev, emptyVolumeEntry(defaultContainer)]);
const removeEntry = (i: number) => setEntries((prev) => prev.filter((_, idx) => idx !== i));
const save = async () => {
for (const entry of entries) {
if (!entry.volume.name.trim()) {
toast({ title: i18n.t('k8s.editStorage.volumeNameRequired'), variant: 'destructive' });
return;
}
if (!formToVolume(entry.volume)) {
const hint = entry.volume.type === 'configMap' ? i18n.t('k8s.editStorage.selectConfigMap')
: entry.volume.type === 'secret' ? i18n.t('k8s.editStorage.selectSecret')
: entry.volume.type === 'persistentVolumeClaim' ? i18n.t('k8s.editStorage.selectPVC')
: i18n.t('k8s.editStorage.typeRequiredHint');
toast({ title: i18n.t('k8s.editStorage.incompleteConfig'), description: `请为「${entry.volume.name}」${hint}`, variant: 'destructive' });
return;
}
const validMounts = entry.mounts.filter((m) => m.mountPath.trim());
if (validMounts.length === 0) {
toast({ title: i18n.t('k8s.editStorage.mountPathRequired'), description: `数据卷「${entry.volume.name}」需至少挂载到一个容器`, variant: 'destructive' });
return;
}
}
setSaving(true);
try {
const { volumes, mounts } = volumeEntriesToStorage(entries);
const base = applyStorageToManifest(rawManifest, volumes, mounts, passthroughVolumes);
await saveDeploymentManifest(clusterCode, namespace, deploymentName, base);
toast({ title: i18n.t('k8s.editStorage.saved'), description: i18n.t('k8s.toast.updatedSubmitted', { name: deploymentName }) });
onSaved(base);
} catch (e) {
toast({ title: i18n.t('kubernetes.msg.saveFailed'), description: (e as Error)?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
};
if (loading) {
return (
<Card className="dg-card">
<CardContent className="p-12 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto dg-text-muted" />
<p className="dg-text-muted mt-3 text-sm"> {i18n.t('monitor.logPanel.logResults.loading')} </p>
</CardContent>
</Card>
);
}
if (!manifest) {
return (
<Card className="dg-card">
<CardContent className="p-8 text-center dg-text-muted"> {i18n.t('kubernetes.storage.noManifest')} </CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{createResource && (
<ResourceVisualEditor
clusterCode={clusterCode}
namespace={namespace}
resource={createResource}
name=""
isCreate
onClose={() => setCreateResource(null)}
onSaved={() => { loadResources(); setCreateResource(null); }}
/>
)}
<Card className="dg-card">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Database className="h-4 w-4 dg-text-green" />
数据卷
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">{entries.length}</Badge>
</CardTitle>
<p className="text-[11px] dg-text-muted mt-1"> {i18n.t('kubernetes.storage.volumeDesc')} </p>
</div>
<Button size="sm" variant="outline" className="h-7 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs shrink-0" onClick={addEntry}>
<Plus className="h-3 w-3 mr-1" />添加数据卷
</Button>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-4">
{entries.length === 0 ? (
<div className="text-center py-10 dg-text-muted text-sm border border-dashed border-[var(--dg-border)] rounded-lg">
<Database className="h-8 w-8 mx-auto mb-2 dg-text-muted" />
未配置数据卷,点击「添加数据卷」开始
</div>
) : entries.map((entry, i) => {
const vol = entry.volume;
const vtype = VOLUME_TYPES.find((t) => t.key === vol.type);
const VIcon = vtype?.icon || Folder;
const selectValue = SELECTABLE_TYPES.has(vol.type) ? vol.type : 'emptyDir';
return (
<div key={i} className="bg-[var(--dg-bg-primary)]/60 border border-[var(--dg-border)] rounded-lg">
{/* 卷定义 */}
<div className="p-3 space-y-2.5 border-b border-[var(--dg-border)]/80">
<div className="flex items-center gap-2 flex-wrap">
<VIcon className={`h-4 w-4 ${vtype?.color || 'dg-text-secondary'}`} />
<span className="text-xs dg-text-secondary shrink-0"> {i18n.t('kubernetes.deploymentEditService.volumeName')} </span>
<Input
value={vol.name}
onChange={(e) => {
const name = e.target.value;
const patch: Partial<VolumeForm> = { name };
if (vol.type === 'configMap' && !vol.configMapName) patch.configMapName = name;
if (vol.type === 'secret' && !vol.secretName) patch.secretName = name;
if (vol.type === 'persistentVolumeClaim' && !vol.pvcName) patch.pvcName = name;
updateVolume(i, patch);
}}
placeholder={i18n.t('k8s.editStorage.namePlaceholder')}
className="h-8 w-44 dg-input text-sm font-mono"
/>
{/* 原生 select,避免 Radix Select 在 overflow 容器内无法下拉 // Logan.Li */}
<select
value={selectValue}
onChange={(e) => {
const type = e.target.value as VolumeForm['type'];
const patch: Partial<VolumeForm> = { type };
if (type === 'configMap' && resources.configMaps.length === 1) patch.configMapName = resources.configMaps[0];
if (type === 'secret' && resources.secrets.length === 1) patch.secretName = resources.secrets[0];
if (type === 'persistentVolumeClaim' && resources.pvcs.length === 1) patch.pvcName = resources.pvcs[0];
updateVolume(i, patch);
}}
className="h-8 min-w-[168px] bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-xs text-[var(--dg-text-secondary)] px-2"
>
{VOLUME_TYPES.map((t) => (
<option key={t.key} value={t.key}>{t.label}</option>
))}
</select>
<Button size="icon" variant="ghost" className="h-8 w-8 dg-text-red ml-auto" onClick={() => removeEntry(i)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<VolumeTypeFields
vol={vol}
resources={resources}
resourcesLoading={resourcesLoading}
onChange={(p) => {
const vol = entries[i]?.volume;
const extra: Partial<VolumeForm> = {};
if (vol && !vol.name.trim()) {
if (p.configMapName) extra.name = p.configMapName;
if (p.secretName) extra.name = p.secretName;
if (p.pvcName) extra.name = p.pvcName;
}
updateVolume(i, { ...p, ...extra });
}}
onRefreshResources={loadResources}
onCreateResource={setCreateResource}
/>
</div>
{/* 挂载到容器(支持一卷多容器,Kuboard 同卡) */}
<div className="px-3 py-2.5 bg-[var(--dg-bg-secondary)]/40 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] dg-text-muted flex items-center gap-1.5">
<Container className="h-3.5 w-3.5 dg-text-blue" />
将上述数据卷挂载到容器
</p>
<Button size="sm" variant="ghost" className="h-6 px-2 text-[11px] dg-text-secondary hover:text-[var(--dg-text-primary)]" onClick={() => addMount(i)}>
<Plus className="h-3 w-3 mr-0.5" />添加挂载
</Button>
</div>
{entry.mounts.map((mount, mi) => (
<div key={mi} className="space-y-2 rounded-md border border-[var(--dg-border)]/60 bg-[var(--dg-bg-primary)]/40 p-2.5">
<div className="flex items-center gap-2 flex-wrap">
<select
value={mount.containerName}
onChange={(e) => updateMount(i, mi, { containerName: e.target.value })}
className="h-8 min-w-[140px] max-w-[280px] bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm text-[var(--dg-text-primary)] px-2 font-medium truncate"
>
{containers.length === 0 && <option value="">(无容器)</option>}
{containers.map((cn) => <option key={cn} value={cn}>{cn}</option>)}
</select>
<div className="flex items-center gap-1 bg-[var(--dg-bg-tertiary)] rounded-lg p-0.5 border border-[var(--dg-border)]">
<button type="button" onClick={() => updateMount(i, mi, { readOnly: true })}
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${mount.readOnly ? 'bg-amber-500/20 text-amber-300' : 'dg-text-muted hover:text-[var(--dg-text-secondary)]'}`}>
只读
</button>
<button type="button" onClick={() => updateMount(i, mi, { readOnly: false })}
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${!mount.readOnly ? 'bg-blue-500/20 text-blue-300' : 'dg-text-muted hover:text-[var(--dg-text-secondary)]'}`}>
读写
</button>
</div>
<Input
value={mount.mountPath}
onChange={(e) => updateMount(i, mi, { mountPath: e.target.value })}
placeholder={i18n.t('k8s.editStorage.mountPathPlaceholder')}
className="h-8 flex-1 min-w-[200px] dg-input text-sm font-mono"
/>
{entry.mounts.length > 1 && (
<Button size="icon" variant="ghost" className="h-8 w-8 dg-text-red/70 shrink-0" onClick={() => removeMount(i, mi)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] dg-text-muted shrink-0"> {i18n.t('kubernetes.deploymentEditService.subPath')} </span>
<Input
value={mount.subPath}
onChange={(e) => updateMount(i, mi, { subPath: e.target.value })}
placeholder={i18n.t('k8s.editStorage.mountPathPlaceholder')}
className="h-7 flex-1 max-w-md dg-input text-[var(--dg-text-secondary)] text-xs font-mono"
/>
</div>
</div>
))}
</div>
</div>
);
})}
</CardContent>
</Card>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={onCancel}> {i18n.t('rbac.jobRole.cancel')} </Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={saving} onClick={save}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
保存
</Button>
</div>
</div>
);
}