feat(k8s): Deployment 存储挂载改 Kuboard 一卷一卡布局

每张数据卷同屏展示卷定义与容器挂载(容器/只读读写/路径/subPath)。
This commit is contained in:
Logan.Li committed 2026-06-26 15:00:09 +08:00
1 parent a8305de0f5
commit 025e74ff4d
98 files changed
+386 -345

No files matched your search

@@ -1,23 +1,23 @@
// DeploymentEditStorage.tsx — 编辑器「存储挂载」Tab
// Kuboard 风格:左侧定义数据卷 / 右侧挂载到容器
// 支持 6 种卷类型:PVC / EmptyDir / HostPath / NFS / ConfigMap / Secret
// 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 { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import {
Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server,
Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server, Container,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { saveDeploymentManifest } from './deployment-manifest-save';
import {
type VolumeForm,
type MountForm,
parseStorageFromManifest,
type VolumeEntry,
parseVolumeEntries,
applyStorageToManifest,
formToVolume,
volumeEntriesToStorage,
emptyVolumeEntry,
} from './deployment-volume-utils';
interface Props {
@@ -29,72 +29,149 @@ interface Props {
onCancel: () => void;
}
const VOLUME_TYPES: { key: VolumeForm['type']; label: string; icon: typeof HardDrive; color: string; desc: string }[] = [
{ key: 'persistentVolumeClaim', label: '存储卷声明', icon: Database, color: 'text-emerald-400', desc: 'PVC' },
{ key: 'emptyDir', label: '临时目录', icon: Disc, color: 'text-slate-400', desc: 'EmptyDir' },
{ key: 'hostPath', label: 'HostPath', icon: HardDrive, color: 'text-amber-400', desc: '主机路径' },
{ key: 'nfs', label: 'NFS', icon: Server, color: 'text-purple-400', desc: '网络存储' },
{ key: 'configMap', label: '配置字典', icon: File, color: 'text-blue-400', desc: 'ConfigMap' },
{ key: 'secret', label: '密文', icon: Lock, color: 'text-rose-400', desc: 'Secret' },
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: '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' },
];
function VolumeTypeFields({ vol, onChange }: { vol: VolumeForm; onChange: (p: Partial<VolumeForm>) => void }) {
if (vol.type === 'persistentVolumeClaim') {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-slate-500 w-20 shrink-0">PVC 名称</span>
<Input value={vol.pvcName || ''} onChange={(e) => onChange({ pvcName: e.target.value })}
placeholder="如 data-pvc" className="h-7 flex-1 min-w-[160px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={!!vol.pvcReadOnly} onChange={(e) => onChange({ pvcReadOnly: e.target.checked })} className="accent-blue-500" />
卷只读
</label>
</div>
);
}
if (vol.type === 'emptyDir') {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-slate-500 w-20 shrink-0">介质</span>
<select value={vol.medium || ''} onChange={(e) => onChange({ medium: e.target.value })}
className="h-7 w-28 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5">
<option value="">默认磁盘</option>
<option value="Memory">Memory</option>
</select>
<Input value={vol.sizeLimit || ''} onChange={(e) => onChange({ sizeLimit: e.target.value })}
placeholder="sizeLimit 如 512Mi" className="h-7 flex-1 min-w-[120px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
);
}
if (vol.type === 'hostPath') {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-slate-500 w-20 shrink-0">主机路径</span>
<Input value={vol.path || ''} onChange={(e) => onChange({ path: e.target.value })}
placeholder="/data/app" className="h-7 flex-1 min-w-[160px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<select value={vol.pathType || 'Directory'} onChange={(e) => onChange({ pathType: e.target.value })}
className="h-7 w-28 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5">
<option value="Directory">Directory</option>
<option value="File">File</option>
<option value="">默认</option>
</select>
</div>
);
}
if (vol.type === 'nfs') {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-slate-500 w-20 shrink-0">NFS</span>
<Input value={vol.nfsServer || ''} onChange={(e) => onChange({ nfsServer: e.target.value })}
placeholder="Server IP" className="h-7 w-36 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Input value={vol.nfsPath || ''} onChange={(e) => onChange({ nfsPath: e.target.value })}
placeholder="/export/path" className="h-7 flex-1 min-w-[120px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
);
}
if (vol.type === 'configMap') {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-slate-500 w-20 shrink-0">ConfigMap</span>
<Input value={vol.configMapName || ''} onChange={(e) => onChange({ configMapName: e.target.value })}
placeholder="ConfigMap 名称" className="h-7 flex-1 min-w-[120px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Input value={vol.cmItems || ''} onChange={(e) => onChange({ cmItems: e.target.value })}
placeholder="key:path(可选)" className="h-7 w-40 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
);
}
if (vol.type === 'secret') {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-slate-500 w-20 shrink-0">Secret</span>
<Input value={vol.secretName || ''} onChange={(e) => onChange({ secretName: e.target.value })}
placeholder="Secret 名称" className="h-7 flex-1 min-w-[120px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Input value={vol.secItems || ''} onChange={(e) => onChange({ secItems: e.target.value })}
placeholder="key:path(可选)" className="h-7 w-40 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
);
}
return null;
}
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<any>(manifest || {});
const [volumes, setVolumes] = useState<VolumeForm[]>([]);
const [mounts, setMounts] = useState<MountForm[]>([]);
const [entries, setEntries] = useState<VolumeEntry[]>([]);
const [containers, setContainers] = useState<string[]>([]);
const [passthroughVolumes, setPassthroughVolumes] = useState<Record<string, any>[]>([]);
useEffect(() => {
const m = manifest;
if (!m) { setLoading(false); return; }
setRawManifest(m);
const parsed = parseStorageFromManifest(m);
setVolumes(parsed.volumes);
setMounts(parsed.mounts);
if (!manifest) { setLoading(false); return; }
setRawManifest(manifest);
const parsed = parseVolumeEntries(manifest);
setEntries(parsed.entries);
setContainers(parsed.containers);
setPassthroughVolumes(parsed.passthroughVolumes);
setLoading(false);
}, [manifest]);
// Volume operations
const addVolume = () => setVolumes(prev => [...prev, { name: '', type: 'emptyDir' }]);
const removeVolume = (i: number) => {
const name = volumes[i]?.name;
setVolumes(prev => prev.filter((_, idx) => idx !== i));
if (name) setMounts(prev => prev.filter(m => m.volumeName !== name));
};
const updateVolume = (i: number, patch: Partial<VolumeForm>) =>
setVolumes(prev => prev.map((v, idx) => (idx === i ? { ...v, ...patch } : v)));
const defaultContainer = containers.find((c) => c !== '(unnamed)') || containers[0] || '';
// Mount operations
const addMount = () => setMounts(prev => [...prev, { containerName: containers[0] || '', volumeName: '', mountPath: '', subPath: '', readOnly: false }]);
const removeMount = (i: number) => setMounts(prev => prev.filter((_, idx) => idx !== i));
const updateMount = (i: number, patch: Partial<MountForm>) =>
setMounts(prev => prev.map((m, idx) => (idx === i ? { ...m, ...patch } : m)));
const updateEntry = (i: number, patch: Partial<VolumeEntry>) =>
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...patch } : e)));
const updateVolume = (i: number, patch: Partial<VolumeForm>) =>
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, volume: { ...e.volume, ...patch } } : e)));
const addEntry = () => setEntries((prev) => [...prev, emptyVolumeEntry(defaultContainer)]);
const removeEntry = (i: number) => setEntries((prev) => prev.filter((_, idx) => idx !== i));
const save = async () => {
for (const vol of volumes) {
if (!vol.name.trim()) {
for (const entry of entries) {
if (!entry.volume.name.trim()) {
toast({ title: '请填写数据卷名称', variant: 'destructive' });
return;
}
if (!formToVolume(vol)) {
toast({ title: '数据卷配置不完整', description: `请补全「${vol.name}」的类型必填项`, variant: 'destructive' });
if (!formToVolume(entry.volume)) {
toast({ title: '数据卷配置不完整', description: `请补全「${entry.volume.name}」的类型必填项`, variant: 'destructive' });
return;
}
if (!entry.mountPath.trim()) {
toast({ title: '请填写挂载路径', description: `数据卷「${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: '存储配置已保存', description: `${deploymentName} 更新已提交` });
onSaved(base);
} catch (e) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
toast({ title: '保存失败', description: (e as Error)?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
};
@@ -112,226 +189,131 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
if (!manifest) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8 text-center text-slate-500">
未获取到 manifest 数据,请使用 YAML 编辑模式直接修改。
</CardContent>
<CardContent className="p-8 text-center text-slate-500">未获取到 manifest 数据,请使用 YAML 编辑模式。</CardContent>
</Card>
);
}
const volumeNames = volumes.map(v => v.name).filter(Boolean);
return (
<div className="space-y-4">
{/* ══════════ 定义数据卷 ══════════ */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Database className="h-4 w-4 text-emerald-400" />定义数据卷
<Badge variant="outline" className="border-slate-700 text-slate-400">{volumes.length}</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={addVolume}>
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Database className="h-4 w-4 text-emerald-400" />
数据卷
<Badge variant="outline" className="border-slate-700 text-slate-400">{entries.length}</Badge>
</CardTitle>
<p className="text-[11px] text-slate-500 mt-1">定义数据卷,并将其挂载到工作负载的容器中</p>
</div>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs shrink-0" onClick={addEntry}>
<Plus className="h-3 w-3 mr-1" />添加数据卷
</Button>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{volumes.length === 0 ? (
<div className="text-center py-8 text-slate-500 text-sm border border-dashed border-slate-800 rounded-lg">
<CardContent className="pt-0 space-y-4">
{entries.length === 0 ? (
<div className="text-center py-10 text-slate-500 text-sm border border-dashed border-slate-800 rounded-lg">
<Database className="h-8 w-8 mx-auto mb-2 text-slate-700" />
未配置数据卷
未配置数据卷,点击「添加数据卷」开始
</div>
) : volumes.map((vol, i) => {
const vtype = VOLUME_TYPES.find(t => t.key === vol.type);
) : entries.map((entry, i) => {
const vol = entry.volume;
const vtype = VOLUME_TYPES.find((t) => t.key === vol.type);
const VIcon = vtype?.icon || Folder;
return (
<div key={i} className="bg-slate-950/50 border border-slate-800 rounded-lg p-3 space-y-2.5">
{/* 卷名 + 类型 */}
<div className="flex items-center gap-2">
<VIcon className={`h-4 w-4 ${vtype?.color || 'text-slate-400'}`} />
<span className="text-[10px] text-slate-500 w-16 shrink-0">数据卷名称</span>
<Input value={vol.name} onChange={e => updateVolume(i, { name: e.target.value })}
placeholder="volume-name"
className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Select value={vol.type} onValueChange={v => updateVolume(i, { type: v as VolumeForm['type'] })}>
<SelectTrigger className="h-7 w-[120px] bg-slate-800 border-slate-700 text-xs text-slate-300">
<div className="flex items-center gap-1.5">
<VIcon className="h-3 w-3" />{vtype?.label || vol.type}
</div>
</SelectTrigger>
<SelectContent>
{VOLUME_TYPES.map(t => {
const TI = t.icon;
return (
<SelectItem key={t.key} value={t.key}>
<span className="flex items-center gap-1.5 text-xs">
<TI className="h-3 w-3" />{t.label}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button size="icon" variant="ghost" className="h-7 w-7 text-red-400" onClick={() => removeVolume(i)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div key={i} className="bg-slate-950/60 border border-slate-800 rounded-lg overflow-hidden">
{/* 卷定义 */}
<div className="p-3 space-y-2.5 border-b border-slate-800/80">
<div className="flex items-center gap-2 flex-wrap">
<VIcon className={`h-4 w-4 ${vtype?.color || 'text-slate-400'}`} />
<span className="text-xs text-slate-400 shrink-0">数据卷名称</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="如 config / uploads / data"
className="h-8 w-40 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>
<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>
</div>
<VolumeTypeFields vol={vol} onChange={(p) => updateVolume(i, p)} />
</div>
{/* 类型指定字段 */}
{vol.type === 'persistentVolumeClaim' && (
<div className="flex items-center gap-2 pl-[88px]">
<Input value={vol.pvcName || ''} onChange={e => updateVolume(i, { pvcName: e.target.value })}
placeholder="PVC 名称" className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={!!vol.pvcReadOnly}
onChange={e => updateVolume(i, { pvcReadOnly: e.target.checked })}
className="accent-blue-500" />只读
</label>
</div>
)}
{vol.type === 'emptyDir' && (
<div className="flex items-center gap-2 pl-[88px]">
<select value={vol.medium || ''} onChange={e => updateVolume(i, { medium: e.target.value })}
className="h-7 w-28 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5">
<option value="">默认磁盘</option>
<option value="Memory">Memory</option>
{/* 挂载到容器(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>
<Input value={vol.sizeLimit || ''} onChange={e => updateVolume(i, { sizeLimit: e.target.value })}
placeholder="sizeLimit (如 512Mi)"
className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<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>
<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>
)}
{vol.type === 'hostPath' && (
<div className="flex items-center gap-2 pl-[88px]">
<Input value={vol.path || ''} onChange={e => updateVolume(i, { path: e.target.value })}
placeholder="主机路径 /data/app"
className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<select value={vol.pathType || 'Directory'} onChange={e => updateVolume(i, { pathType: e.target.value })}
className="h-7 w-28 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5">
<option value="Directory">Directory</option>
<option value="File">File</option>
<option value="">(默认)</option>
</select>
<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>
)}
{vol.type === 'nfs' && (
<div className="flex items-center gap-2 pl-[88px]">
<Input value={vol.nfsServer || ''} onChange={e => updateVolume(i, { nfsServer: e.target.value })}
placeholder="NFS Server IP"
className="h-7 w-40 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<span className="text-slate-600 text-xs">:</span>
<Input value={vol.nfsPath || ''} onChange={e => updateVolume(i, { nfsPath: e.target.value })}
placeholder="/export/path"
className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={!!vol.nfsReadOnly}
onChange={e => updateVolume(i, { nfsReadOnly: e.target.checked })}
className="accent-blue-500" />只读
</label>
</div>
)}
{vol.type === 'configMap' && (
<div className="flex items-center gap-2 pl-[88px]">
<span className="text-[10px] text-slate-500 whitespace-nowrap">ConfigMap</span>
<Input value={vol.configMapName || ''} onChange={e => updateVolume(i, { configMapName: e.target.value })}
placeholder="ConfigMap 名称"
className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Input value={vol.cmItems || ''} onChange={e => updateVolume(i, { cmItems: e.target.value })}
placeholder="key:path 或 JSON"
className="h-7 w-40 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
)}
{vol.type === 'secret' && (
<div className="flex items-center gap-2 pl-[88px]">
<span className="text-[10px] text-slate-500 whitespace-nowrap">密文</span>
<Input value={vol.secretName || ''} onChange={e => updateVolume(i, { secretName: e.target.value })}
placeholder="Secret 名称"
className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Input value={vol.secItems || ''} onChange={e => updateVolume(i, { secItems: e.target.value })}
placeholder="key:path 或 JSON"
className="h-7 w-40 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
)}
</div>
</div>
);
})}
</CardContent>
</Card>
{/* ══════════ 挂载到容器 ══════════ */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Folder className="h-4 w-4 text-blue-400" />将数据卷挂载到容器
<Badge variant="outline" className="border-slate-700 text-slate-400">{mounts.length}</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={addMount}
disabled={volumeNames.length === 0 || containers.length === 0}>
<Plus className="h-3 w-3 mr-1" />添加挂载
</Button>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{mounts.length === 0 ? (
<div className="text-center py-8 text-slate-500 text-sm border border-dashed border-slate-800 rounded-lg">
<Folder className="h-8 w-8 mx-auto mb-2 text-slate-700" />
{volumeNames.length === 0 ? '请先定义数据卷' : '未配置挂载点'}
</div>
) : mounts.map((m, i) => (
<div key={i} className="flex items-center gap-2 bg-slate-950/50 border border-slate-800 rounded-lg p-2.5">
{/* 容器选择 */}
<span className="text-[10px] text-slate-500 whitespace-nowrap">容器:</span>
<select value={m.containerName} onChange={e => updateMount(i, { containerName: e.target.value })}
className="h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5 min-w-[120px]">
{containers.length === 0 && <option value="">(无容器)</option>}
{containers.map(cn => <option key={cn} value={cn}>{cn}</option>)}
</select>
{/* 数据卷选择 */}
<select value={m.volumeName} onChange={e => updateMount(i, { volumeName: e.target.value })}
className="h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5 min-w-[100px]">
<option value="">选择数据卷</option>
{volumeNames.map(vn => <option key={vn} value={vn}>{vn}</option>)}
</select>
{/* 挂载路径 */}
<Input value={m.mountPath} onChange={e => updateMount(i, { mountPath: e.target.value })}
placeholder="挂载路径 /app/data"
className="h-7 flex-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Input value={m.subPath} onChange={e => updateMount(i, { subPath: e.target.value })}
placeholder="subPath (可选)"
className="h-7 w-28 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
{/* 只读/读写 */}
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-0.5 border border-slate-700">
<button onClick={() => updateMount(i, { readOnly: false })}
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-all ${!m.readOnly ? 'bg-blue-500/20 text-blue-300' : 'text-slate-500'}`}>
读写
</button>
<button onClick={() => updateMount(i, { readOnly: true })}
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-all ${m.readOnly ? 'bg-amber-500/20 text-amber-300' : 'text-slate-500'}`}>
只读
</button>
</div>
<Button size="icon" variant="ghost" className="h-7 w-7 text-red-400" onClick={() => removeMount(i)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</CardContent>
</Card>
{/* 操作栏 */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onCancel}>取消</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" />}保存
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
保存
</Button>
</div>
</div>
);
}
}
@@ -37,6 +37,25 @@ export interface MountForm {
readOnly: boolean;
}
/** Kuboard 风格:一卷一卡(卷定义 + 挂载同屏)// Logan.Li */
export interface VolumeEntry {
volume: VolumeForm;
containerName: string;
mountPath: string;
subPath: string;
readOnly: boolean;
}
export function emptyVolumeEntry(defaultContainer = ''): VolumeEntry {
return {
volume: { name: '', type: 'emptyDir' },
containerName: defaultContainer,
mountPath: '',
subPath: '',
readOnly: false,
};
}
const SYSTEM_VOLUME_PREFIXES = ['kube-api-access-', 'default-token-', 'kube-'];
/** 是否系统/不可编辑卷(保存时保留)// Logan.Li */
@@ -250,6 +269,46 @@ export function parseStorageFromManifest(manifest: Record<string, any>): {
};
}
/** 解析为 Kuboard 一卷一卡条目 // Logan.Li */
export function parseVolumeEntries(manifest: Record<string, any>): {
entries: VolumeEntry[];
containers: string[];
passthroughVolumes: Record<string, any>[];
} {
const { volumes, mounts, containers, passthroughVolumes } = parseStorageFromManifest(manifest);
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,
};
});
return { entries, containers, passthroughVolumes };
}
/** 一卷一卡 → volumes + mounts // Logan.Li */
export function volumeEntriesToStorage(entries: VolumeEntry[]): {
volumes: VolumeForm[];
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,
}));
return { volumes, mounts };
}
/** 将表单写回 manifest.spec.template.spec // Logan.Li */
export function applyStorageToManifest(
manifest: Record<string, any>,