Files
aiops/frontend/src/components/kubernetes/DeploymentEditStorage.tsx
T
attacker c6e0d6ca57 refactor(i18n): U42 残余硬编码中文彻底清零 — 33文件54处→0, zh/en 7847→7898对齐
全量 JSX children + toast/confirm + sr-only 描述硬编码中文清零:
- Kubernetes模块: UpdateStrategyCard maxSurge/maxUnavailable说明 + IngressCreateForm host域名/Service端口 + ServiceCreateForm 端口配置/selector + DeploymentEditService 无selector/个关联/暂无Service/Ingress + ClusterOverviewPage 核 + KubernetesClusterDialog 已配置 + KubectlTerminal/ResourceVisualEditor/CreateHPADialog/DeploymentEditContainers/Storage + PodInstancesTable/UpdateStrategySelectorCards
- AI模块: PredictiveCapacityTab AI深度解读/紧急/警告/7天预测/预计N天 + PredictiveCostTab AI深度解读 + event-handler.ts 高危操作确认toast
- Deploy模块: DeployCredentials 启用/禁用 + DeployRBAC 删除角色confirm + WorkspaceTermDialog + CredFormDialog Windows用户
- Monitor模块: AlertRulesTab 删除confirm/分钟 + PrometheusDatasourceManager/LogDatasourcePage 删除confirm + QueryHistoryPanel 条
- System模块: ServerFileBrowser/TerminalDialog/RdpAuditViewer sr-only + SystemManager/CredentialManager/TerminalSessionsTab 删除confirm + SecuritySettingsTab 30分钟/2小时
- Task模块: CronJobViewDialog 次

新增 61 翻译键 (k8s.editAdvanced 6 + k8s.editService 11 + k8s.cluster 2 + k8s.misc 9 + aiops.capacity 9 + aiops.cost 1 + aiops.cli 2 + deploy 4 + monitor 5 + system 10 + task 1 + common 2)
tsc -b=0, npm build✓ (17.71s), ESLint=0/43/any=0, go build=0, 17/17 测试通过
2026-07-14 00:51:36 +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: i18n.t('k8s.editStorage.incompleteConfigDesc', { name: 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: i18n.t('k8s.editStorage.mountPathRequiredDesc', { name: 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" />{i18n.t("common.addVolume")}
</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" />{i18n.t("common.addMount")}
</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="">{i18n.t('k8s.cluster.noContainer')}</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>
);
}