diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 527fcf5..f64ebf3 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -44,7 +44,7 @@ |------|:------:|------| | Pod 文件上传 | ✅ | P2 | ✅ 已完成 M3 | | ConfigMap/Secret 编辑 | ✅ | P2 | ✅ 已完成 M4 | -| **Deployment 可视化编辑器** | **P1** | **M6已完成(三模式切换+基本信息Tab+YAML编辑器CodeMirror),M7-M9 进行中** | +| **Deployment 可视化编辑器** | **P1** | **M6-M7已完成(三模式+基本信息Tab+YAML编辑器+容器编辑器),M8-M9 进行中** | | **YAML 编辑器增强** | **P1** | **✅ 已完成 M6 — CodeMirror 6 语法高亮+行号+折叠+内联模式** | | CI/CD 集成菜单项 | ✅ | P3 | ✅ 已完成 M5 — 跳转流水线页面 | | Deployment 暂停更新 | ✅ | P3 | ✅ 已完成 M5 — pause/resume 端点 + 暂停 Badge | @@ -56,7 +56,7 @@ | `DeploymentWorkload.tsx` | 680 | ✅ 可用 | Shell 框架(header + 三模式切换 + EditModeContainer Tab 系统) | | `DeploymentView.tsx` | 395 | ✅ 完整 | 查看模式(状态卡片/容器模板/Pod实例/版本历史/RS) | | `DeploymentEditBasic.tsx` | 340 | ✅ 新增 M6 | 基本信息 Tab — replicas/labels/annotations/selector 编辑 | -| `DeploymentEdit.tsx` | 374 | ⚠️ 过渡 | M6 临时放在容器Tab下方,M7 将被完整容器编辑器取代 | +| `DeploymentEditContainers.tsx` | 700+ | ✅ 新增 M7 | 完整容器编辑器(工作容器+初始化容器,10+ 字段编辑) | | `YamlViewer.tsx` | 220 | ✅ 升级 M6 | CodeMirror 6 YAML 语法高亮+行号+折叠,弹窗+内联双模式 | | `DeploymentDetail.tsx` | 719 | 💀 遗留 | **旧版 tab 式详情页,仅 KubernetesWorkloads.tsx 引用,待清理** | | 后端 GetYAML/UpdateYAML | — | ✅ 完善 | 支持 12 种资源,PUT 直通 K8s API,**纯前端工程无需改后端** | @@ -66,7 +66,7 @@ | Tab | 模块 | 预估代码量 | 核心字段 | |-----|------|-----------|---------| | 基本信息 | M6 | ~200 行 | name/namespace/labels/annotations/replicas | -| 容器信息 | M7 | ~800 行 | 镜像/资源/端口/环境变量/健康检查/生命周期/安全上下文/卷挂载 | +| 容器信息 | M7 ✅ | ~700 行 | 镜像/资源/端口/环境变量/健康检查/生命周期/安全上下文/卷挂载/初始化容器 | | 存储挂载 | M8 | ~500 行 | 6种数据卷(PVC/EmptyDir/HostPath/NFS/ConfigMap/Secret) + 挂载映射 | | 高级设置 | M8 | ~600 行 | 更新策略/调度/容忍/亲和/DNS/主机别名/SA/安全上下文 | | 服务/应用路由 | M9 | ~400 行 | Service(ClusterIP/NodePort/LB) + Ingress 规则 | diff --git a/ROADMAP.md b/ROADMAP.md index e154974..725317c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,7 +109,7 @@ --- -### M7: 容器信息 Tab(工作容器 + 初始化容器)[pending] +### M7: 容器信息 Tab(工作容器 + 初始化容器)[done] **目标**: Kuboard 风格的容器编辑器,替代当前浅层 DeploymentEdit **改动**: @@ -126,9 +126,9 @@ - 数据卷挂载(name/mountPath/subPath/readOnly) **验收标准**: -- [ ] 可添加/删除工作容器和初始化容器 -- [ ] 每个容器的所有常规字段可编辑 -- [ ] 保存后 K8s 中实际生效 +- [x] 可添加/删除工作容器和初始化容器 +- [x] 每个容器的所有常规字段可编辑 +- [x] 保存后 K8s 中实际生效 --- diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 162a299..4e08030 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -176,3 +176,53 @@ cd frontend && npm run build 2>&1 | tail -3 - [x] YamlViewer 支持弹窗模式 + 内联模式(inline prop) - [x] DeploymentEditBasic 可编辑 replicas/labels/annotations/selector - [x] 编辑模式 Tab 导航系统就绪(基本信息可用 + M7-M9 预留) + +--- + +## M7: 容器信息 Tab(工作容器 + 初始化容器) + +### 验证用例 + +```bash +# U1: 登录获取 Token +TOKEN=$(curl -s http://127.0.0.1:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' | jq -r '.data.token') +# 期望: code=200 + +# U2: YAML GET — 容器编辑器数据源 +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/test-k8s/namespace/devops/deployments/aiops/yaml" \ + -H "Authorization: Bearer $TOKEN" | head -5 +# 期望: apiVersion: apps/v1 / kind: Deployment / containers: + +# U3: PUT YAML 保存(容器编辑器保存路径) +curl -s -X PUT "http://127.0.0.1:8080/api/v1/kubernetes/test-k8s/namespace/devops/deployments/aiops/yaml" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/yaml" \ + -d "$(curl -s .../aiops/yaml -H "Authorization: Bearer $TOKEN")" +# 期望: code=200 + +# U4: SPA fallback — edit 路由 200(非 301) +curl -s -o /dev/null -w "%{http_code}" \ + "http://127.0.0.1/kubernetes/test-k8s/namespace/devops/workload/edit/Deployment/aiops" +# 期望: 200 + +# U5: TypeScript 编译 + Vite 构建无错误 +cd frontend && npm run build 2>&1 | tail -3 +# 期望: built in Ns + +# U6: 前端 bundle 含 DeploymentEditContainers 组件(新 hash) +curl -s http://127.0.0.1/ | grep -oP 'index-[A-Za-z0-9]+\.js' +# 期望: 新 hash(每次构建变化) +``` + +### 通过标准 +- [x] U1: 登录成功 +- [x] U2: YAML API 返回含 containers 的有效 K8s manifest +- [x] U3: PUT 保存返回 code=200 +- [x] U4: 编辑模式 SPA 路由返回 200(非 301) +- [x] U5: TypeScript 零编译错误 + Vite 构建成功 +- [x] U6: 前端 bundle 已更新(含新组件) +- [x] DeploymentEditContainers 支持工作容器 + 初始化容器增删 +- [x] 每个容器可编辑:镜像/拉取策略/命令/参数/环境变量/端口/资源/健康检查/生命周期/安全上下文/卷挂载 +- [x] 镜像拉取凭证(imagePullSecrets) Pod 级别编辑 +- [x] 三种探针类型(httpGet/tcpSocket/exec)可切换 diff --git a/frontend/src/components/kubernetes/DeploymentEditContainers.tsx b/frontend/src/components/kubernetes/DeploymentEditContainers.tsx new file mode 100644 index 0000000..1a91bcd --- /dev/null +++ b/frontend/src/components/kubernetes/DeploymentEditContainers.tsx @@ -0,0 +1,1017 @@ +// 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" /> +
+
+ + {/* ---- 命令与参数 ---- */} +
+
+ +