// DeploymentEditStorage — 存储挂载 Tab(Kuboard 一卷一卡:定义卷 + 多容器挂载)// Logan.Li import { useState, useEffect, useCallback } 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 { Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock, File, Disc, Server, Container, } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { kubernetesApi } from '@/api'; import { saveDeploymentManifest } from './deployment-manifest-save'; import { ResourceVisualEditor } from './ResourceVisualEditor'; import { VolumeTypeFields, extractResourceNames, type NamespaceResourceLists, } from './deployment-storage-fields'; import { type VolumeForm, type VolumeEntry, type VolumeMountRow, parseVolumeEntries, applyStorageToManifest, formToVolume, volumeEntriesToStorage, emptyVolumeEntry, emptyMountRow, } from './deployment-volume-utils'; import type { JsonData } from '@/types/common'; interface Props { clusterCode: string; namespace: string; deploymentName: string; manifest: JsonData | null; onSaved: (updated?: JsonData) => void; onCancel: () => void; } 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: '临时目录(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)); type CreateResourceKind = 'configmaps' | 'secrets' | 'pvcs'; 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 [entries, setEntries] = useState([]); const [containers, setContainers] = useState([]); const [passthroughVolumes, setPassthroughVolumes] = useState([]); const [resources, setResources] = useState({ configMaps: [], secrets: [], pvcs: [] }); const [resourcesLoading, setResourcesLoading] = useState(false); const [createResource, setCreateResource] = useState(null); const loadResources = useCallback(async () => { if (!clusterCode || !namespace) return; setResourcesLoading(true); try { const [cm, sec, pvc] = await Promise.all([ kubernetesApi.getNamespaceConfigMaps(clusterCode, namespace).catch(() => []), kubernetesApi.getNamespaceSecrets(clusterCode, namespace).catch(() => []), kubernetesApi.getPVCs(clusterCode, namespace).catch(() => []), ]); setResources({ configMaps: extractResourceNames(cm), secrets: extractResourceNames(sec), pvcs: extractResourceNames(pvc), }); } finally { setResourcesLoading(false); } }, [clusterCode, namespace]); useEffect(() => { loadResources(); }, [loadResources]); useEffect(() => { if (!manifest) { setLoading(false); return; } setRawManifest(manifest); const parsed = parseVolumeEntries(manifest); setEntries(parsed.entries); setContainers(parsed.containers); setPassthroughVolumes(parsed.passthroughVolumes); setLoading(false); }, [manifest]); const defaultContainer = containers.find((c) => c !== '(unnamed)') || containers[0] || ''; const updateEntry = (i: number, patch: Partial) => setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...patch } : e))); const updateVolume = (i: number, patch: Partial) => setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, volume: { ...e.volume, ...patch } } : e))); const updateMount = (entryIdx: number, mountIdx: number, patch: Partial) => 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)); const save = async () => { for (const entry of entries) { if (!entry.volume.name.trim()) { toast({ title: '请填写数据卷名称', variant: 'destructive' }); return; } if (!formToVolume(entry.volume)) { const hint = entry.volume.type === 'configMap' ? '请选择 ConfigMap' : entry.volume.type === 'secret' ? '请选择 Secret' : entry.volume.type === 'persistentVolumeClaim' ? '请选择 PVC' : '的类型必填项'; toast({ title: '数据卷配置不完整', description: `请为「${entry.volume.name}」${hint}`, variant: 'destructive' }); return; } const validMounts = entry.mounts.filter((m) => m.mountPath.trim()); if (validMounts.length === 0) { 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 as Error)?.message || String(e), variant: 'destructive' }); } finally { setSaving(false); } }; if (loading) { return (

加载中...

); } if (!manifest) { return ( 未获取到 manifest 数据,请使用 YAML 编辑模式。 ); } return (
{createResource && ( setCreateResource(null)} onSaved={() => { loadResources(); setCreateResource(null); }} /> )}
数据卷 {entries.length}

定义数据卷,并将其挂载到工作负载的容器中

{entries.length === 0 ? (
未配置数据卷,点击「添加数据卷」开始
) : entries.map((entry, i) => { 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 (
{/* 卷定义 */}
数据卷名称 { const name = e.target.value; const patch: Partial = { 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="如 app-logs / fluent-bit-config" className="h-8 w-44 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> {/* 原生 select,避免 Radix Select 在 overflow 容器内无法下拉 // Logan.Li */}
{ const vol = entries[i]?.volume; const extra: Partial = {}; if (vol && !vol.name.trim()) { if (p.configMapName) extra.name = p.configMapName; if (p.secretName) extra.name = p.secretName; if (p.pvcName) extra.name = p.pvcName; } updateVolume(i, { ...p, ...extra }); }} onRefreshResources={loadResources} onCreateResource={setCreateResource} />
{/* 挂载到容器(支持一卷多容器,Kuboard 同卡) */}

将上述数据卷挂载到容器

{entry.mounts.map((mount, mi) => (
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 && ( )}
子路径 subPath 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" />
))}
); })}
); }