feat(k8s): Deployment 存储挂载改 Kuboard 一卷一卡布局
每张数据卷同屏展示卷定义与容器挂载(容器/只读读写/路径/subPath)。
This commit is contained in:
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>,
|
||||
|
||||
Reference in new issue
Block a user