fix(k8s): Deployment 存储挂载类型下拉 + 一卷多容器

- 卷类型改用原生 select,修复 Radix Select 在 overflow 容器内无法展开
- 支持一卷挂载多个容器(如 app-logs 同时挂主容器与 sidecar)
- 类型文案对齐 Kuboard:临时目录(EmptyDir) 等六种
This commit is contained in:
Logan.Li committed 2026-06-26 15:08:40 +08:00
1 parent 729f25e068
commit ee2588fb52
2 files changed
+138 -79

No files matched your search

@@ -1,10 +1,9 @@
// DeploymentEditStorage — 存储挂载 Tab(Kuboard 一卷一卡:定义卷 + 挂载同屏)// Logan.Li // DeploymentEditStorage — 存储挂载 Tab(Kuboard 一卷一卡:定义卷 + 多容器挂载)// Logan.Li
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { import {
Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server, Container, Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server, Container,
} from 'lucide-react'; } from 'lucide-react';
@@ -13,11 +12,13 @@ import { saveDeploymentManifest } from './deployment-manifest-save';
import { import {
type VolumeForm, type VolumeForm,
type VolumeEntry, type VolumeEntry,
type VolumeMountRow,
parseVolumeEntries, parseVolumeEntries,
applyStorageToManifest, applyStorageToManifest,
formToVolume, formToVolume,
volumeEntriesToStorage, volumeEntriesToStorage,
emptyVolumeEntry, emptyVolumeEntry,
emptyMountRow,
} from './deployment-volume-utils'; } from './deployment-volume-utils';
interface Props { interface Props {
@@ -31,13 +32,15 @@ interface Props {
const VOLUME_TYPES: { key: VolumeForm['type']; label: string; icon: typeof HardDrive; color: string }[] = [ const VOLUME_TYPES: { key: VolumeForm['type']; label: string; icon: typeof HardDrive; color: string }[] = [
{ key: 'persistentVolumeClaim', label: '存储卷声明', icon: Database, color: 'text-emerald-400' }, { 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: 'hostPath', label: 'HostPath', icon: HardDrive, color: 'text-amber-400' },
{ key: 'nfs', label: 'NFS', icon: Server, color: 'text-purple-400' }, { key: 'nfs', label: 'NFS', icon: Server, color: 'text-purple-400' },
{ key: 'configMap', label: '配置字典', icon: File, color: 'text-blue-400' }, { key: 'configMap', label: '配置字典', icon: File, color: 'text-blue-400' },
{ key: 'secret', label: '密文', icon: Lock, color: 'text-rose-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<VolumeForm>) => void }) { function VolumeTypeFields({ vol, onChange }: { vol: VolumeForm; onChange: (p: Partial<VolumeForm>) => void }) {
if (vol.type === 'persistentVolumeClaim') { if (vol.type === 'persistentVolumeClaim') {
return ( return (
@@ -114,6 +117,9 @@ function VolumeTypeFields({ vol, onChange }: { vol: VolumeForm; onChange: (p: Pa
</div> </div>
); );
} }
if (vol.type === 'custom') {
return <p className="text-[11px] text-amber-400/80">未识别卷类型,保存时将保留原始 YAML 结构</p>;
}
return null; return null;
} }
@@ -144,6 +150,28 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
const updateVolume = (i: number, patch: Partial<VolumeForm>) => const updateVolume = (i: number, patch: Partial<VolumeForm>) =>
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, volume: { ...e.volume, ...patch } } : e))); 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 addEntry = () => setEntries((prev) => [...prev, emptyVolumeEntry(defaultContainer)]);
const removeEntry = (i: number) => setEntries((prev) => prev.filter((_, idx) => idx !== i)); 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' }); toast({ title: '数据卷配置不完整', description: `请补全「${entry.volume.name}」的类型必填项`, variant: 'destructive' });
return; return;
} }
if (!entry.mountPath.trim()) { const validMounts = entry.mounts.filter((m) => m.mountPath.trim());
toast({ title: '请填写挂载路径', description: `数据卷「${entry.volume.name}」需指定挂载到容器的路径`, variant: 'destructive' }); if (validMounts.length === 0) {
toast({ title: '请填写挂载路径', description: `数据卷「${entry.volume.name}」需至少挂载到一个容器`, variant: 'destructive' });
return; return;
} }
} }
@@ -222,8 +251,9 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
const vol = entry.volume; const vol = entry.volume;
const vtype = VOLUME_TYPES.find((t) => t.key === vol.type); const vtype = VOLUME_TYPES.find((t) => t.key === vol.type);
const VIcon = vtype?.icon || Folder; const VIcon = vtype?.icon || Folder;
const selectValue = SELECTABLE_TYPES.has(vol.type) ? vol.type : 'emptyDir';
return ( return (
<div key={i} className="bg-slate-950/60 border border-slate-800 rounded-lg overflow-hidden"> <div key={i} className="bg-slate-950/60 border border-slate-800 rounded-lg">
{/* 卷定义 */} {/* 卷定义 */}
<div className="p-3 space-y-2.5 border-b border-slate-800/80"> <div className="p-3 space-y-2.5 border-b border-slate-800/80">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -239,19 +269,19 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
if (vol.type === 'persistentVolumeClaim' && !vol.pvcName) patch.pvcName = name; if (vol.type === 'persistentVolumeClaim' && !vol.pvcName) patch.pvcName = name;
updateVolume(i, patch); updateVolume(i, patch);
}} }}
placeholder="如 config / uploads / data" placeholder="如 app-logs / fluent-bit-config"
className="h-8 w-40 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" className="h-8 w-44 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono"
/> />
<Select value={vol.type} onValueChange={(v) => updateVolume(i, { type: v as VolumeForm['type'] })}> {/* 原生 select,避免 Radix Select 在 overflow 容器内无法下拉 // Logan.Li */}
<SelectTrigger className="h-8 w-[128px] bg-slate-800 border-slate-700 text-xs text-slate-300"> <select
<span>{vtype?.label || vol.type}</span> value={selectValue}
</SelectTrigger> onChange={(e) => updateVolume(i, { type: e.target.value as VolumeForm['type'] })}
<SelectContent> className="h-8 min-w-[168px] bg-slate-800 border border-slate-700 rounded-md text-xs text-slate-300 px-2"
{VOLUME_TYPES.map((t) => ( >
<SelectItem key={t.key} value={t.key}>{t.label}</SelectItem> {VOLUME_TYPES.map((t) => (
))} <option key={t.key} value={t.key}>{t.label}</option>
</SelectContent> ))}
</Select> </select>
<Button size="icon" variant="ghost" className="h-8 w-8 text-red-400 ml-auto" onClick={() => removeEntry(i)}> <Button size="icon" variant="ghost" className="h-8 w-8 text-red-400 ml-auto" onClick={() => removeEntry(i)}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -259,47 +289,61 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
<VolumeTypeFields vol={vol} onChange={(p) => updateVolume(i, p)} /> <VolumeTypeFields vol={vol} onChange={(p) => updateVolume(i, p)} />
</div> </div>
{/* 挂载到容器(Kuboard 同卡) */} {/* 挂载到容器(支持一卷多容器,Kuboard 同卡) */}
<div className="px-3 py-2.5 bg-slate-900/40 space-y-2"> <div className="px-3 py-2.5 bg-slate-900/40 space-y-2">
<p className="text-[11px] text-slate-500 flex items-center gap-1.5"> <div className="flex items-center justify-between gap-2">
<Container className="h-3.5 w-3.5 text-blue-400" /> <p className="text-[11px] text-slate-500 flex items-center gap-1.5">
将上述数据卷挂载到容器 <Container className="h-3.5 w-3.5 text-blue-400" />
</p> 将上述数据卷挂载到容器
<div className="flex items-center gap-2 flex-wrap"> </p>
<select <Button size="sm" variant="ghost" className="h-6 px-2 text-[11px] text-slate-400 hover:text-slate-200" onClick={() => addMount(i)}>
value={entry.containerName} <Plus className="h-3 w-3 mr-0.5" />添加挂载
onChange={(e) => updateEntry(i, { containerName: e.target.value })} </Button>
className="h-8 min-w-[120px] bg-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 px-2 font-medium" </div>
> {entry.mounts.map((mount, mi) => (
{containers.length === 0 && <option value="">(无容器)</option>} <div key={mi} className="space-y-2 rounded-md border border-slate-800/60 bg-slate-950/40 p-2.5">
{containers.map((cn) => <option key={cn} value={cn}>{cn}</option>)} <div className="flex items-center gap-2 flex-wrap">
</select> <select
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-0.5 border border-slate-700"> value={mount.containerName}
<button type="button" onClick={() => updateEntry(i, { readOnly: true })} onChange={(e) => updateMount(i, mi, { containerName: e.target.value })}
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${entry.readOnly ? 'bg-amber-500/20 text-amber-300' : 'text-slate-500 hover:text-slate-300'}`}> className="h-8 min-w-[140px] max-w-[280px] bg-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 px-2 font-medium truncate"
只读 >
</button> {containers.length === 0 && <option value="">(无容器)</option>}
<button type="button" onClick={() => updateEntry(i, { readOnly: false })} {containers.map((cn) => <option key={cn} value={cn}>{cn}</option>)}
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${!entry.readOnly ? 'bg-blue-500/20 text-blue-300' : 'text-slate-500 hover:text-slate-300'}`}> </select>
读写 <div className="flex items-center gap-1 bg-slate-800 rounded-lg p-0.5 border border-slate-700">
</button> <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' : 'text-slate-500 hover:text-slate-300'}`}>
只读
</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' : 'text-slate-500 hover:text-slate-300'}`}>
读写
</button>
</div>
<Input
value={mount.mountPath}
onChange={(e) => 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 && (
<Button size="icon" variant="ghost" className="h-8 w-8 text-red-400/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] text-slate-500 shrink-0">子路径 subPath</span>
<Input
value={mount.subPath}
onChange={(e) => 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"
/>
</div>
</div> </div>
<Input ))}
value={entry.mountPath}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500 shrink-0">子路径 subPath</span>
<Input
value={entry.subPath}
onChange={(e) => 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"
/>
</div>
</div> </div>
</div> </div>
); );
@@ -37,22 +37,28 @@ export interface MountForm {
readOnly: boolean; readOnly: boolean;
} }
/** Kuboard 风格:一卷一卡(卷定义 + 挂载同屏)// Logan.Li */ /** 单条容器挂载行 // Logan.Li */
export interface VolumeEntry { export interface VolumeMountRow {
volume: VolumeForm;
containerName: string; containerName: string;
mountPath: string; mountPath: string;
subPath: string; subPath: string;
readOnly: boolean; 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 { export function emptyVolumeEntry(defaultContainer = ''): VolumeEntry {
return { return {
volume: { name: '', type: 'emptyDir' }, volume: { name: '', type: 'emptyDir' },
containerName: defaultContainer, mounts: [emptyMountRow(defaultContainer)],
mountPath: '',
subPath: '',
readOnly: false,
}; };
} }
@@ -269,7 +275,7 @@ export function parseStorageFromManifest(manifest: Record<string, any>): {
}; };
} }
/** 解析为 Kuboard 一卷一卡条目 // Logan.Li */ /** 解析为 Kuboard 一卷一卡条目(支持一卷多容器挂载)// Logan.Li */
export function parseVolumeEntries(manifest: Record<string, any>): { export function parseVolumeEntries(manifest: Record<string, any>): {
entries: VolumeEntry[]; entries: VolumeEntry[];
containers: string[]; containers: string[];
@@ -279,13 +285,16 @@ export function parseVolumeEntries(manifest: Record<string, any>): {
const defaultContainer = containers.find((c) => c !== '(unnamed)') || containers[0] || ''; const defaultContainer = containers.find((c) => c !== '(unnamed)') || containers[0] || '';
const entries: VolumeEntry[] = volumes.map((vol) => { const entries: VolumeEntry[] = volumes.map((vol) => {
const matched = mounts.filter((m) => m.volumeName === vol.name); const matched = mounts.filter((m) => m.volumeName === vol.name);
const primary = matched[0];
return { return {
volume: vol, volume: vol,
containerName: primary?.containerName || defaultContainer, mounts: matched.length > 0
mountPath: primary?.mountPath || '', ? matched.map((m) => ({
subPath: primary?.subPath || '', containerName: m.containerName,
readOnly: primary?.readOnly ?? false, mountPath: m.mountPath,
subPath: m.subPath,
readOnly: m.readOnly,
}))
: [emptyMountRow(defaultContainer)],
}; };
}); });
return { entries, containers, passthroughVolumes }; return { entries, containers, passthroughVolumes };
@@ -297,15 +306,21 @@ export function volumeEntriesToStorage(entries: VolumeEntry[]): {
mounts: MountForm[]; mounts: MountForm[];
} { } {
const volumes = entries.map((e) => e.volume); const volumes = entries.map((e) => e.volume);
const mounts: MountForm[] = entries const mounts: MountForm[] = [];
.filter((e) => e.volume.name.trim() && e.mountPath.trim()) for (const entry of entries) {
.map((e) => ({ const volName = entry.volume.name.trim();
containerName: e.containerName, if (!volName) continue;
volumeName: e.volume.name.trim(), for (const row of entry.mounts) {
mountPath: e.mountPath.trim(), if (!row.mountPath.trim()) continue;
subPath: e.subPath.trim(), mounts.push({
readOnly: e.readOnly, containerName: row.containerName,
})); volumeName: volName,
mountPath: row.mountPath.trim(),
subPath: row.subPath.trim(),
readOnly: row.readOnly,
});
}
}
return { volumes, mounts }; return { volumes, mounts };
} }