diff --git a/frontend/src/components/kubernetes/DeploymentEditStorage.tsx b/frontend/src/components/kubernetes/DeploymentEditStorage.tsx index b587a33..fd12aba 100644 --- a/frontend/src/components/kubernetes/DeploymentEditStorage.tsx +++ b/frontend/src/components/kubernetes/DeploymentEditStorage.tsx @@ -1,10 +1,9 @@ -// DeploymentEditStorage — 存储挂载 Tab(Kuboard 一卷一卡:定义卷 + 挂载同屏)// Logan.Li +// DeploymentEditStorage — 存储挂载 Tab(Kuboard 一卷一卡:定义卷 + 多容器挂载)// Logan.Li import { useState, useEffect } 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 { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; import { Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server, Container, } from 'lucide-react'; @@ -13,11 +12,13 @@ import { saveDeploymentManifest } from './deployment-manifest-save'; import { type VolumeForm, type VolumeEntry, + type VolumeMountRow, parseVolumeEntries, applyStorageToManifest, formToVolume, volumeEntriesToStorage, emptyVolumeEntry, + emptyMountRow, } from './deployment-volume-utils'; interface Props { @@ -31,13 +32,15 @@ interface Props { const VOLUME_TYPES: { key: VolumeForm['type']; label: string; icon: typeof HardDrive; color: string }[] = [ { key: 'persistentVolumeClaim', label: '存储卷声明', icon: Database, color: 'text-emerald-400' }, - { key: 'emptyDir', label: '临时目录', icon: Disc, color: 'text-slate-400' }, + { key: 'emptyDir', label: '临时目录(EmptyDir)', icon: Disc, color: 'text-slate-400' }, { key: 'hostPath', label: 'HostPath', icon: HardDrive, color: 'text-amber-400' }, { key: 'nfs', label: 'NFS', icon: Server, color: 'text-purple-400' }, { key: 'configMap', label: '配置字典', icon: File, color: 'text-blue-400' }, { key: 'secret', label: '密文', icon: Lock, color: 'text-rose-400' }, ]; +const SELECTABLE_TYPES = new Set(VOLUME_TYPES.map((t) => t.key)); + function VolumeTypeFields({ vol, onChange }: { vol: VolumeForm; onChange: (p: Partial) => void }) { if (vol.type === 'persistentVolumeClaim') { return ( @@ -114,6 +117,9 @@ function VolumeTypeFields({ vol, onChange }: { vol: VolumeForm; onChange: (p: Pa ); } + if (vol.type === 'custom') { + return

未识别卷类型,保存时将保留原始 YAML 结构

; + } return null; } @@ -144,6 +150,28 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName, const updateVolume = (i: number, patch: Partial) => setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, volume: { ...e.volume, ...patch } } : e))); + const updateMount = (entryIdx: number, mountIdx: number, patch: Partial) => + 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)); @@ -158,8 +186,9 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName, toast({ title: '数据卷配置不完整', description: `请补全「${entry.volume.name}」的类型必填项`, variant: 'destructive' }); return; } - if (!entry.mountPath.trim()) { - toast({ title: '请填写挂载路径', description: `数据卷「${entry.volume.name}」需指定挂载到容器的路径`, variant: 'destructive' }); + const validMounts = entry.mounts.filter((m) => m.mountPath.trim()); + if (validMounts.length === 0) { + toast({ title: '请填写挂载路径', description: `数据卷「${entry.volume.name}」需至少挂载到一个容器`, variant: 'destructive' }); return; } } @@ -222,8 +251,9 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName, 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 ( -
+
{/* 卷定义 */}
@@ -239,19 +269,19 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName, if (vol.type === 'persistentVolumeClaim' && !vol.pvcName) patch.pvcName = name; updateVolume(i, patch); }} - placeholder="如 config / uploads / data" - className="h-8 w-40 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" + placeholder="如 app-logs / fluent-bit-config" + className="h-8 w-44 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> - + {/* 原生 select,避免 Radix Select 在 overflow 容器内无法下拉 // Logan.Li */} + @@ -259,47 +289,61 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName, updateVolume(i, p)} />
- {/* 挂载到容器(Kuboard 同卡) */} + {/* 挂载到容器(支持一卷多容器,Kuboard 同卡) */}
-

- - 将上述数据卷挂载到容器 -

-
- -
- - +
+

+ + 将上述数据卷挂载到容器 +

+ +
+ {entry.mounts.map((mount, mi) => ( +
+
+ +
+ + +
+ updateMount(i, mi, { mountPath: e.target.value })} + placeholder="挂载路径 /bitcloud/app/.../logs" + className="h-8 flex-1 min-w-[200px] bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" + /> + {entry.mounts.length > 1 && ( + + )} +
+
+ 子路径 subPath + updateMount(i, mi, { subPath: e.target.value })} + placeholder="如 config.yaml(挂载单文件时填写)" + className="h-7 flex-1 max-w-md bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" + /> +
- updateEntry(i, { mountPath: e.target.value })} - placeholder="挂载路径 /app/data" - className="h-8 flex-1 min-w-[180px] bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" - /> -
-
- 子路径 subPath - updateEntry(i, { subPath: e.target.value })} - placeholder="如 config.yaml(挂载单文件时填写)" - className="h-7 flex-1 max-w-md bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" - /> -
+ ))}
); diff --git a/frontend/src/components/kubernetes/deployment-volume-utils.ts b/frontend/src/components/kubernetes/deployment-volume-utils.ts index b47f308..900ebd6 100644 --- a/frontend/src/components/kubernetes/deployment-volume-utils.ts +++ b/frontend/src/components/kubernetes/deployment-volume-utils.ts @@ -37,22 +37,28 @@ export interface MountForm { readOnly: boolean; } -/** Kuboard 风格:一卷一卡(卷定义 + 挂载同屏)// Logan.Li */ -export interface VolumeEntry { - volume: VolumeForm; +/** 单条容器挂载行 // Logan.Li */ +export interface VolumeMountRow { containerName: string; mountPath: string; subPath: string; readOnly: boolean; } +/** Kuboard 风格:一卷一卡(卷定义 + 多容器挂载同屏)// Logan.Li */ +export interface VolumeEntry { + volume: VolumeForm; + mounts: VolumeMountRow[]; +} + +export function emptyMountRow(defaultContainer = ''): VolumeMountRow { + return { containerName: defaultContainer, mountPath: '', subPath: '', readOnly: false }; +} + export function emptyVolumeEntry(defaultContainer = ''): VolumeEntry { return { volume: { name: '', type: 'emptyDir' }, - containerName: defaultContainer, - mountPath: '', - subPath: '', - readOnly: false, + mounts: [emptyMountRow(defaultContainer)], }; } @@ -269,7 +275,7 @@ export function parseStorageFromManifest(manifest: Record): { }; } -/** 解析为 Kuboard 一卷一卡条目 // Logan.Li */ +/** 解析为 Kuboard 一卷一卡条目(支持一卷多容器挂载)// Logan.Li */ export function parseVolumeEntries(manifest: Record): { entries: VolumeEntry[]; containers: string[]; @@ -279,13 +285,16 @@ export function parseVolumeEntries(manifest: Record): { const defaultContainer = containers.find((c) => c !== '(unnamed)') || containers[0] || ''; const entries: VolumeEntry[] = volumes.map((vol) => { const matched = mounts.filter((m) => m.volumeName === vol.name); - const primary = matched[0]; return { volume: vol, - containerName: primary?.containerName || defaultContainer, - mountPath: primary?.mountPath || '', - subPath: primary?.subPath || '', - readOnly: primary?.readOnly ?? false, + mounts: matched.length > 0 + ? matched.map((m) => ({ + containerName: m.containerName, + mountPath: m.mountPath, + subPath: m.subPath, + readOnly: m.readOnly, + })) + : [emptyMountRow(defaultContainer)], }; }); return { entries, containers, passthroughVolumes }; @@ -297,15 +306,21 @@ export function volumeEntriesToStorage(entries: VolumeEntry[]): { mounts: MountForm[]; } { const volumes = entries.map((e) => e.volume); - const mounts: MountForm[] = entries - .filter((e) => e.volume.name.trim() && e.mountPath.trim()) - .map((e) => ({ - containerName: e.containerName, - volumeName: e.volume.name.trim(), - mountPath: e.mountPath.trim(), - subPath: e.subPath.trim(), - readOnly: e.readOnly, - })); + const mounts: MountForm[] = []; + for (const entry of entries) { + const volName = entry.volume.name.trim(); + if (!volName) continue; + for (const row of entry.mounts) { + if (!row.mountPath.trim()) continue; + mounts.push({ + containerName: row.containerName, + volumeName: volName, + mountPath: row.mountPath.trim(), + subPath: row.subPath.trim(), + readOnly: row.readOnly, + }); + } + } return { volumes, mounts }; }