全量 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 测试通过
351 lines
17 KiB
TypeScript
351 lines
17 KiB
TypeScript
// 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>
|
||
);
|
||
} |