fix(k8s): Deployment 存储挂载类型下拉 + 一卷多容器
- 卷类型改用原生 select,修复 Radix Select 在 overflow 容器内无法展开 - 支持一卷挂载多个容器(如 app-logs 同时挂主容器与 sidecar) - 类型文案对齐 Kuboard:临时目录(EmptyDir) 等六种
This commit is contained in:
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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user