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 { 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<VolumeForm>) => void }) {
if (vol.type === 'persistentVolumeClaim') {
return (
@@ -114,6 +117,9 @@ function VolumeTypeFields({ vol, onChange }: { vol: VolumeForm; onChange: (p: Pa
</div>
);
}
if (vol.type === 'custom') {
return <p className="text-[11px] text-amber-400/80">未识别卷类型,保存时将保留原始 YAML 结构</p>;
}
return null;
}
@@ -144,6 +150,28 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
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));
@@ -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 (
<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="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;
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 value={vol.type} onValueChange={(v) => updateVolume(i, { type: v as VolumeForm['type'] })}>
<SelectTrigger className="h-8 w-[128px] bg-slate-800 border-slate-700 text-xs text-slate-300">
<span>{vtype?.label || vol.type}</span>
</SelectTrigger>
<SelectContent>
{VOLUME_TYPES.map((t) => (
<SelectItem key={t.key} value={t.key}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
{/* 原生 select,避免 Radix Select 在 overflow 容器内无法下拉 // Logan.Li */}
<select
value={selectValue}
onChange={(e) => updateVolume(i, { type: e.target.value as VolumeForm['type'] })}
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) => (
<option key={t.key} value={t.key}>{t.label}</option>
))}
</select>
<Button size="icon" variant="ghost" className="h-8 w-8 text-red-400 ml-auto" onClick={() => removeEntry(i)}>
<Trash2 className="h-4 w-4" />
</Button>
@@ -259,47 +289,61 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
<VolumeTypeFields vol={vol} onChange={(p) => updateVolume(i, p)} />
</div>
{/* 挂载到容器(Kuboard 同卡) */}
{/* 挂载到容器(支持一卷多容器,Kuboard 同卡) */}
<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">
<Container className="h-3.5 w-3.5 text-blue-400" />
将上述数据卷挂载到容器
</p>
<div className="flex items-center gap-2 flex-wrap">
<select
value={entry.containerName}
onChange={(e) => updateEntry(i, { containerName: e.target.value })}
className="h-8 min-w-[120px] bg-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 px-2 font-medium"
>
{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-slate-800 rounded-lg p-0.5 border border-slate-700">
<button type="button" onClick={() => updateEntry(i, { readOnly: true })}
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'}`}>
只读
</button>
<button type="button" onClick={() => updateEntry(i, { readOnly: false })}
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'}`}>
读写
</button>
<div className="flex items-center justify-between gap-2">
<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>
<Button size="sm" variant="ghost" className="h-6 px-2 text-[11px] text-slate-400 hover:text-slate-200" 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-slate-800/60 bg-slate-950/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-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 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-slate-800 rounded-lg p-0.5 border border-slate-700">
<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>
<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>
);
@@ -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<string, any>): {
};
}
/** 解析为 Kuboard 一卷一卡条目 // Logan.Li */
/** 解析为 Kuboard 一卷一卡条目(支持一卷多容器挂载)// Logan.Li */
export function parseVolumeEntries(manifest: Record<string, any>): {
entries: VolumeEntry[];
containers: string[];
@@ -279,13 +285,16 @@ export function parseVolumeEntries(manifest: Record<string, any>): {
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 };
}