// DeploymentEditContainers.tsx — 编辑器「容器信息」Tab (M7) // Kuboard 风格容器编辑器:工作容器 + 初始化容器完整编辑 // 编辑字段:镜像/拉取策略/命令/参数/环境变量/资源/端口/健康检查/生命周期/安全上下文/卷挂载 // 作者: Logan import { useState, useEffect, useMemo } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; import { Plus, Trash2, Save, Loader2, Container, Cpu, Heart, FolderOpen, ChevronDown, ChevronRight, Terminal, Shield, Zap, Network, KeyRound, Package, Play, Settings2, RotateCw, } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { kubernetesApi } from '@/services/api'; // ============ Types ============ interface EnvVar { name: string; value?: string; } interface PortDef { name?: string; containerPort: number | string; protocol?: string; } interface VolumeMount { name: string; mountPath: string; subPath?: string; readOnly?: boolean; } interface ResourceMap { cpu?: string; memory?: string; } interface ProbeDef { type?: 'httpGet' | 'tcpSocket' | 'exec' | 'none'; // httpGet httpPath?: string; httpPort?: number | string; httpHost?: string; // tcpSocket tcpPort?: number | string; // exec execCommand?: string; // timing initialDelaySeconds?: number; periodSeconds?: number; timeoutSeconds?: number; successThreshold?: number; failureThreshold?: number; } interface LifecycleHandler { type?: 'exec' | 'httpGet' | 'tcpSocket' | 'none'; execCommand?: string; httpPath?: string; httpPort?: number | string; tcpPort?: number | string; } interface LifecycleDef { postStart?: LifecycleHandler; preStop?: LifecycleHandler; } interface SecurityContext { runAsUser?: number | string; runAsGroup?: number | string; runAsNonRoot?: boolean; privileged?: boolean; readOnlyRootFilesystem?: boolean; allowPrivilegeEscalation?: boolean; capabilitiesAdd?: string[]; capabilitiesDrop?: string[]; } interface ContainerForm { name: string; image: string; imagePullPolicy?: string; command: string[]; args: string[]; workingDir?: string; env: EnvVar[]; ports: PortDef[]; resources: { requests?: ResourceMap; limits?: ResourceMap; }; livenessProbe?: ProbeDef | null; readinessProbe?: ProbeDef | null; startupProbe?: ProbeDef | null; lifecycle?: LifecycleDef | null; securityContext?: SecurityContext | null; volumeMounts: VolumeMount[]; } interface DeploymentEditContainersProps { clusterCode: string; namespace: string; deploymentName: string; manifest: any | null; onSaved: () => void; onCancel: () => void; } // ============ K8s container spec → form conversion ============ function toContainerForm(c: any): ContainerForm { const probe = (p: any): ProbeDef | null => { if (!p) return null; let type: ProbeDef['type'] = 'none'; if (p.httpGet) type = 'httpGet'; else if (p.tcpSocket) type = 'tcpSocket'; else if (p.exec) type = 'exec'; return { type, httpPath: p.httpGet?.path || '', httpPort: p.httpGet?.port ?? '', httpHost: p.httpGet?.host || '', tcpPort: p.tcpSocket?.port ?? '', execCommand: Array.isArray(p.exec?.command) ? p.exec.command.join('\n') : (p.exec?.command || ''), initialDelaySeconds: p.initialDelaySeconds, periodSeconds: p.periodSeconds, timeoutSeconds: p.timeoutSeconds, successThreshold: p.successThreshold, failureThreshold: p.failureThreshold, }; }; const lh = (h: any): LifecycleHandler => { if (!h) return { type: 'none' }; let type: LifecycleHandler['type'] = 'none'; if (h.exec) type = 'exec'; else if (h.httpGet) type = 'httpGet'; else if (h.tcpSocket) type = 'tcpSocket'; return { type, execCommand: Array.isArray(h.exec?.command) ? h.exec.command.join('\n') : '', httpPath: h.httpGet?.path || '', httpPort: h.httpGet?.port ?? '', tcpPort: h.tcpSocket?.port ?? '', }; }; const sc = c.securityContext; return { name: c.name || '', image: c.image || '', imagePullPolicy: c.imagePullPolicy || '', command: Array.isArray(c.command) ? c.command : [], args: Array.isArray(c.args) ? c.args : [], workingDir: c.workingDir || '', env: (Array.isArray(c.env) ? c.env : []).map((e: any) => ({ name: e.name || '', value: e.value ?? '' })), ports: (Array.isArray(c.ports) ? c.ports : []).map((p: any) => ({ name: p.name || '', containerPort: p.containerPort ?? '', protocol: p.protocol || 'TCP' })), resources: { requests: c.resources?.requests || {}, limits: c.resources?.limits || {}, }, livenessProbe: probe(c.livenessProbe), readinessProbe: probe(c.readinessProbe), startupProbe: probe(c.startupProbe), lifecycle: c.lifecycle ? { postStart: c.lifecycle.postStart ? lh(c.lifecycle.postStart) : undefined, preStop: c.lifecycle.preStop ? lh(c.lifecycle.preStop) : undefined, } : null, securityContext: sc ? { runAsUser: sc.runAsUser, runAsGroup: sc.runAsGroup, runAsNonRoot: sc.runAsNonRoot, privileged: sc.privileged, readOnlyRootFilesystem: sc.readOnlyRootFilesystem, allowPrivilegeEscalation: sc.allowPrivilegeEscalation, capabilitiesAdd: sc.capabilities?.add || [], capabilitiesDrop: sc.capabilities?.drop || [], } : null, volumeMounts: (Array.isArray(c.volumeMounts) ? c.volumeMounts : []).map((m: any) => ({ name: m.name || '', mountPath: m.mountPath || '', subPath: m.subPath || '', readOnly: m.readOnly || false, })), }; } // ============ form → K8s container spec ============ function emptyContainer(name = ''): ContainerForm { return { name, image: '', imagePullPolicy: 'IfNotPresent', command: [], args: [], workingDir: '', env: [], ports: [], resources: {}, livenessProbe: null, readinessProbe: null, startupProbe: null, lifecycle: null, securityContext: null, volumeMounts: [], }; } function probeToK8s(p: ProbeDef | null | undefined): any { if (!p || p.type === 'none' || !p.type) return undefined; const out: any = {}; if (p.type === 'httpGet') { out.httpGet = {}; if (p.httpPath) out.httpGet.path = p.httpPath; if (p.httpPort !== '' && p.httpPort != null) out.httpGet.port = typeof p.httpPort === 'number' ? p.httpPort : Number(p.httpPort); if (p.httpHost) out.httpGet.host = p.httpHost; } else if (p.type === 'tcpSocket') { out.tcpSocket = {}; if (p.tcpPort !== '' && p.tcpPort != null) out.tcpSocket.port = typeof p.tcpPort === 'number' ? p.tcpPort : Number(p.tcpPort); } else if (p.type === 'exec') { const cmds = (p.execCommand || '').split('\n').map(s => s.trim()).filter(Boolean); if (cmds.length) out.exec = { command: cmds }; } if (p.initialDelaySeconds != null) out.initialDelaySeconds = p.initialDelaySeconds; if (p.periodSeconds != null) out.periodSeconds = p.periodSeconds; if (p.timeoutSeconds != null) out.timeoutSeconds = p.timeoutSeconds; if (p.successThreshold != null) out.successThreshold = p.successThreshold; if (p.failureThreshold != null) out.failureThreshold = p.failureThreshold; return Object.keys(out).length > 0 ? out : undefined; } function handlerToK8s(h: LifecycleHandler | undefined): any { if (!h || h.type === 'none' || !h.type) return undefined; if (h.type === 'exec') { const cmds = (h.execCommand || '').split('\n').map(s => s.trim()).filter(Boolean); return cmds.length ? { exec: { command: cmds } } : undefined; } if (h.type === 'httpGet') { const o: any = { httpGet: {} }; if (h.httpPath) o.httpGet.path = h.httpPath; if (h.httpPort !== '' && h.httpPort != null) o.httpGet.port = Number(h.httpPort); return o; } if (h.type === 'tcpSocket') { const o: any = { tcpSocket: {} }; if (h.tcpPort !== '' && h.tcpPort != null) o.tcpSocket.port = Number(h.tcpPort); return o; } return undefined; } function containerToK8s(c: ContainerForm): any { const out: any = { name: c.name }; if (c.image) out.image = c.image; if (c.imagePullPolicy) out.imagePullPolicy = c.imagePullPolicy; if (c.workingDir) out.workingDir = c.workingDir; if (c.command.length > 0) out.command = c.command; if (c.args.length > 0) out.args = c.args; if (c.env.length > 0) { out.env = c.env.filter(e => e.name.trim()).map(e => { const o: any = { name: e.name }; if (e.value != null) o.value = e.value; return o; }); } if (c.ports.length > 0) { out.ports = c.ports.filter(p => p.containerPort !== '').map(p => { const o: any = { containerPort: typeof p.containerPort === 'number' ? p.containerPort : Number(p.containerPort) }; if (p.name) o.name = p.name; if (p.protocol) o.protocol = p.protocol; return o; }); } const req = c.resources.requests; const lim = c.resources.limits; const hasReq = req && Object.values(req).some(v => v && v.trim()); const hasLim = lim && Object.values(lim).some(v => v && v.trim()); if (hasReq || hasLim) { out.resources = {}; if (hasReq) { out.resources.requests = {}; if (req!.cpu?.trim()) out.resources.requests.cpu = req!.cpu; if (req!.memory?.trim()) out.resources.requests.memory = req!.memory; } if (hasLim) { out.resources.limits = {}; if (lim!.cpu?.trim()) out.resources.limits.cpu = lim!.cpu; if (lim!.memory?.trim()) out.resources.limits.memory = lim!.memory; } } const lp = probeToK8s(c.livenessProbe); if (lp) out.livenessProbe = lp; const rp = probeToK8s(c.readinessProbe); if (rp) out.readinessProbe = rp; const sp = probeToK8s(c.startupProbe); if (sp) out.startupProbe = sp; if (c.lifecycle) { const lc: any = {}; const ps = handlerToK8s(c.lifecycle.postStart); if (ps) lc.postStart = ps; const pst = handlerToK8s(c.lifecycle.preStop); if (pst) lc.preStop = pst; if (Object.keys(lc).length > 0) out.lifecycle = lc; } if (c.securityContext) { const s = c.securityContext; const sc: any = {}; if (s.runAsUser !== '' && s.runAsUser != null) sc.runAsUser = Number(s.runAsUser); if (s.runAsGroup !== '' && s.runAsGroup != null) sc.runAsGroup = Number(s.runAsGroup); if (s.runAsNonRoot != null) sc.runAsNonRoot = s.runAsNonRoot; if (s.privileged != null) sc.privileged = s.privileged; if (s.readOnlyRootFilesystem != null) sc.readOnlyRootFilesystem = s.readOnlyRootFilesystem; if (s.allowPrivilegeEscalation != null) sc.allowPrivilegeEscalation = s.allowPrivilegeEscalation; const capAdd = (s.capabilitiesAdd || []).filter(Boolean); const capDrop = (s.capabilitiesDrop || []).filter(Boolean); if (capAdd.length || capDrop.length) { sc.capabilities = {}; if (capAdd.length) sc.capabilities.add = capAdd; if (capDrop.length) sc.capabilities.drop = capDrop; } if (Object.keys(sc).length > 0) out.securityContext = sc; } if (c.volumeMounts.length > 0) { out.volumeMounts = c.volumeMounts.filter(m => m.name.trim() && m.mountPath.trim()).map(m => { const o: any = { name: m.name, mountPath: m.mountPath }; if (m.subPath) o.subPath = m.subPath; if (m.readOnly) o.readOnly = true; return o; }); } return out; } // ============ String array helper (command/args editing) ============ function arrToText(arr: string[]): string { return arr.join('\n'); } function textToArr(text: string): string[] { return text.split('\n').map(s => s.trim()).filter(Boolean); } // ============ Main Component ============ export function DeploymentEditContainers({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: DeploymentEditContainersProps) { const { toast } = useToast(); const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(!manifest); const [rawManifest, setRawManifest] = useState(manifest || {}); const [containers, setContainers] = useState([]); const [initContainers, setInitContainers] = useState([]); const [imagePullSecrets, setImagePullSecrets] = useState([]); useEffect(() => { if (!manifest) { setLoading(false); return; } setRawManifest(manifest); const podSpec = manifest?.spec?.template?.spec || {}; setContainers((Array.isArray(podSpec.containers) ? podSpec.containers : []).map(toContainerForm)); setInitContainers((Array.isArray(podSpec.initContainers) ? podSpec.initContainers : []).map(toContainerForm)); setImagePullSecrets((Array.isArray(podSpec.imagePullSecrets) ? podSpec.imagePullSecrets : []).map((s: any) => s.name || '')); setLoading(false); }, [manifest]); // ---- container list ops ---- const updateContainer = (idx: number, patch: Partial) => { setContainers(prev => prev.map((c, i) => (i === idx ? { ...c, ...patch } : c))); }; const addContainer = () => { setContainers(prev => [...prev, emptyContainer(`container-${prev.length + 1}`)]); }; const removeContainer = (idx: number) => { setContainers(prev => prev.filter((_, i) => i !== idx)); }; const duplicateContainer = (idx: number) => { setContainers(prev => { const copy = JSON.parse(JSON.stringify(prev[idx])); copy.name = `${copy.name}-copy`; const next = [...prev]; next.splice(idx + 1, 0, copy); return next; }); }; const updateInit = (idx: number, patch: Partial) => { setInitContainers(prev => prev.map((c, i) => (i === idx ? { ...c, ...patch } : c))); }; const addInit = () => { setInitContainers(prev => [...prev, emptyContainer(`init-${prev.length + 1}`)]); }; const removeInit = (idx: number) => { setInitContainers(prev => prev.filter((_, i) => i !== idx)); }; // ---- save ---- const save = async () => { // validate for (const c of [...initContainers, ...containers]) { if (!c.name.trim()) { toast({ title: '保存失败', description: '容器名称不能为空', variant: 'destructive' }); return; } if (!c.image.trim()) { toast({ title: '保存失败', description: `容器 ${c.name} 的镜像不能为空`, variant: 'destructive' }); return; } } 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 || {}; base.spec.template.spec.containers = containers.map(containerToK8s); base.spec.template.spec.initContainers = initContainers.length > 0 ? initContainers.map(containerToK8s) : undefined; base.spec.template.spec.imagePullSecrets = imagePullSecrets.filter(s => s.trim()).length > 0 ? imagePullSecrets.filter(s => s.trim()).map((s: string) => ({ name: s })) : undefined; 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 编辑模式直接修改。 ); } return (
{/* Pod 级别设置:镜像拉取凭证 */} 镜像拉取凭证 (imagePullSecrets) Pod 级别
{imagePullSecrets.map((sec, i) => (
{sec}
))} {imagePullSecrets.length === 0 && 无}
{ if (e.key === 'Enter') { const val = (e.target as HTMLInputElement).value.trim(); if (val) { setImagePullSecrets(prev => [...prev, val]); (e.target as HTMLInputElement).value = ''; } } }} />
{/* 初始化容器 */} } containers={initContainers} accentColor="purple" onAdd={addInit} onRemove={removeInit} onUpdate={updateInit} /> {/* 工作容器 */} } containers={containers} accentColor="blue" onAdd={addContainer} onRemove={removeContainer} onUpdate={updateContainer} onDuplicate={duplicateContainer} /> {/* 保存栏 */}
共 {initContainers.length} 个初始化容器 + {containers.length} 个工作容器
); } // ============ Container Group Section ============ function ContainerGroupSection({ title, description, icon, containers, accentColor, onAdd, onRemove, onUpdate, onDuplicate, }: { title: string; description: string; icon: React.ReactNode; containers: ContainerForm[]; accentColor: 'blue' | 'purple'; onAdd: () => void; onRemove: (idx: number) => void; onUpdate: (idx: number, patch: Partial) => void; onDuplicate?: (idx: number) => void; }) { const [openIndices, setOpenIndices] = useState>(new Set([0])); const toggleOpen = (idx: number) => { setOpenIndices(prev => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; }); }; const borderClass = accentColor === 'blue' ? 'border-blue-500/30' : 'border-purple-500/30'; const headerBg = accentColor === 'blue' ? 'bg-blue-500/5' : 'bg-purple-500/5'; return (
{icon} {title} {containers.length}

{description}

{containers.length === 0 && (
暂无容器,点击「新增容器」添加
)} {containers.map((c, idx) => { const isOpen = openIndices.has(idx) || containers.length === 1; return (
{/* container header */}
toggleOpen(idx)}>
{isOpen ? : } {c.name || '(未命名)'} {c.image && {c.image}}
e.stopPropagation()}> {onDuplicate && ( )}
{/* container body */} {isOpen && (
onUpdate(idx, patch)} />
)}
); })}
); } // ============ Single Container Editor (all sections) ============ function ContainerEditor({ container, onUpdate }: { container: ContainerForm; onUpdate: (patch: Partial) => void; }) { const c = container; const sectionBtn = "flex items-center gap-1.5 text-xs font-semibold text-slate-300 mb-2"; return ( <> {/* ---- 基本设置 ---- */}
onUpdate({ name: e.target.value })} placeholder="my-app" className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
onUpdate({ image: e.target.value })} placeholder="nginx:1.25" className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
onUpdate({ workingDir: e.target.value })} placeholder="/app" className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
{/* ---- 命令与参数 ---- */}