// DeploymentEditStorage.tsx — 编辑器「存储挂载」Tab // Kuboard 风格:左侧定义数据卷 / 右侧挂载到容器 // 支持 6 种卷类型:PVC / EmptyDir / HostPath / NFS / ConfigMap / Secret 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, } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { kubernetesApi } from '@/services/api'; interface Props { clusterCode: string; namespace: string; deploymentName: string; manifest: any | null; onSaved: () => void; onCancel: () => void; } /** 数据卷定义 */ interface VolumeForm { name: string; type: 'persistentVolumeClaim' | 'emptyDir' | 'hostPath' | 'nfs' | 'configMap' | 'secret'; // PVC pvcName?: string; pvcReadOnly?: boolean; // EmptyDir medium?: string; sizeLimit?: string; // HostPath path?: string; pathType?: string; // NFS nfsServer?: string; nfsPath?: string; nfsReadOnly?: boolean; // ConfigMap configMapName?: string; cmItems?: string; // Secret secretName?: string; secItems?: string; } /** 容器挂载点 */ interface MountForm { containerName: string; volumeName: string; mountPath: string; subPath: string; readOnly: boolean; } 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' }, ]; function volumeToForm( v: any): VolumeForm { if (!v || typeof v !== 'object') return { name: '', type: 'emptyDir' }; const name = v.name || ''; if (v.emptyDir) return { name, type: 'emptyDir', medium: v.emptyDir.medium || '', sizeLimit: v.emptyDir.sizeLimit || '' }; if (v.hostPath) return { name, type: 'hostPath', path: v.hostPath.path || '', pathType: v.hostPath.type || 'Directory' }; if (v.configMap) return { name, type: 'configMap', configMapName: v.configMap.name || '', cmItems: v.configMap.items ? JSON.stringify(v.configMap.items) : '' }; if (v.secret) return { name, type: 'secret', secretName: v.secret.secretName || '', secItems: v.secret.items ? JSON.stringify(v.secret.items) : '' }; if (v.persistentVolumeClaim) return { name, type: 'persistentVolumeClaim', pvcName: v.persistentVolumeClaim.claimName || '', pvcReadOnly: !!v.persistentVolumeClaim.readOnly }; if (v.nfs) return { name, type: 'nfs', nfsServer: v.nfs.server || '', nfsPath: v.nfs.path || '', nfsReadOnly: !!v.nfs.readOnly }; return { name, type: 'emptyDir' }; } function formToVolume(f: VolumeForm): any { const v: Record = { name: f.name.trim() }; if (!v.name) return null; switch (f.type) { case 'emptyDir': { const ed: Record = {}; if (f.medium) ed.medium = f.medium; if (f.sizeLimit) ed.sizeLimit = f.sizeLimit; v.emptyDir = ed; break; } case 'hostPath': v.hostPath = { path: f.path || '', type: f.pathType || 'Directory' }; break; case 'configMap': { v.configMap = { name: f.configMapName || '' }; if (f.cmItems) try { v.configMap.items = JSON.parse(f.cmItems); } catch {} break; } case 'secret': { v.secret = { secretName: f.secretName || '' }; if (f.secItems) try { v.secret.items = JSON.parse(f.secItems); } catch {} break; } case 'persistentVolumeClaim': v.persistentVolumeClaim = { claimName: f.pvcName || '', ...(f.pvcReadOnly ? { readOnly: true } : {}) }; break; case 'nfs': v.nfs = { server: f.nfsServer || '', path: f.nfsPath || '', ...(f.nfsReadOnly ? { readOnly: true } : {}) }; break; } return v; } 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(manifest || {}); const [volumes, setVolumes] = useState([]); const [mounts, setMounts] = useState([]); const [containers, setContainers] = useState([]); useEffect(() => { const m = manifest; if (!m) { setLoading(false); return; } setRawManifest(m); const podSpec = m?.spec?.template?.spec || {}; // 数据卷 const vols: Record[] = Array.isArray(podSpec.volumes) ? podSpec.volumes : []; setVolumes(vols.map(volumeToForm)); // 容器列表 const allContainers = [ ...(podSpec.containers || []), ...(podSpec.initContainers || []), ]; const containerNames = allContainers.map(( c: any) => c.name || '(unnamed)'); setContainers(containerNames); // 挂载点 const allMounts: MountForm[] = []; for (const c of allContainers) { const cName = c.name || '(unnamed)'; const vms: Record[] = Array.isArray(c.volumeMounts) ? c.volumeMounts : []; for (const vm of vms) { allMounts.push({ containerName: cName, volumeName: vm.name || '', mountPath: vm.mountPath || '', subPath: vm.subPath || '', readOnly: !!vm.readOnly, }); } } setMounts(allMounts); 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) => setVolumes(prev => prev.map((v, idx) => (idx === i ? { ...v, ...patch } : v))); // 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) => setMounts(prev => prev.map((m, idx) => (idx === i ? { ...m, ...patch } : m))); const save = async () => { setSaving(true); try { const base = JSON.parse(JSON.stringify(rawManifest)); base.spec = base.spec || {}; base.spec.template = base.spec.template || {}; base.spec.template.spec = base.spec.template.spec || {}; const podSpec = base.spec.template.spec; // Save volumes const k8sVols = volumes.map(formToVolume).filter(Boolean); if (k8sVols.length > 0) podSpec.volumes = k8sVols; else delete podSpec.volumes; // Save volumeMounts to each container const allContainers = [...(podSpec.containers || []), ...(podSpec.initContainers || [])]; for (const c of allContainers) { const cName = c.name || '(unnamed)'; const cMounts = mounts .filter(m => m.containerName === cName && m.volumeName && m.mountPath) .map(m => { const vm: Record = { name: m.volumeName, mountPath: m.mountPath, readOnly: m.readOnly }; if (m.subPath) vm.subPath = m.subPath; return vm; }); if (cMounts.length > 0) c.volumeMounts = cMounts; else delete c.volumeMounts; } // Rebuild containers arrays podSpec.containers = allContainers.filter(( c: any) => !(podSpec.initContainers || []).includes(c)); podSpec.initContainers = (podSpec.initContainers || []); const json = JSON.stringify(base, null, 2); await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json); toast({ title: '存储配置已保存', description: `${deploymentName} 更新已提交` }); onSaved(); } catch (e: any) { toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' }); } finally { setSaving(false); } }; if (loading) { return (

加载中...

); } if (!manifest) { return ( 未获取到 manifest 数据,请使用 YAML 编辑模式直接修改。 ); } const volumeNames = volumes.map(v => v.name).filter(Boolean); return (
{/* ══════════ 定义数据卷 ══════════ */}
定义数据卷 {volumes.length}
{volumes.length === 0 ? (
未配置数据卷
) : volumes.map((vol, i) => { const vtype = VOLUME_TYPES.find(t => t.key === vol.type); const VIcon = vtype?.icon || Folder; return (
{/* 卷名 + 类型 */}
数据卷名称 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" />
{/* 类型指定字段 */} {vol.type === 'persistentVolumeClaim' && (
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" />
)} {vol.type === 'emptyDir' && (
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" />
)} {vol.type === 'hostPath' && (
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" />
)} {vol.type === 'nfs' && (
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" /> : 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" />
)} {vol.type === 'configMap' && (
ConfigMap 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" /> updateVolume(i, { cmItems: e.target.value })} placeholder="KeyToPath (可为空)" className="h-7 w-40 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
)} {vol.type === 'secret' && (
密文 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" /> updateVolume(i, { secItems: e.target.value })} placeholder="KeyToPath (可为空)" className="h-7 w-40 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
)}
); })}
{/* ══════════ 挂载到容器 ══════════ */}
将数据卷挂载到容器 {mounts.length}
{mounts.length === 0 ? (
{volumeNames.length === 0 ? '请先定义数据卷' : '未配置挂载点'}
) : mounts.map((m, i) => (
{/* 容器选择 */} 容器: {/* 数据卷选择 */} {/* 挂载路径 */} 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" /> {/* 只读/读写 */}
))}
{/* 操作栏 */}
); }