- 新增 DeploymentEditContainers.tsx (~700行): Kuboard 风格容器编辑器
- 工作容器 + 初始化容器分组管理(增/删/复制)
- 完整字段编辑: 镜像/拉取策略/命令/参数/工作目录
- 环境变量/端口(TCP/UDP/SCTP)/资源requests-limits
- 健康检查: liveness/readiness/startup三种探针
httpGet/tcpSocket/exec 三种检测方式可切换
- 生命周期回调: postStart/preStop (exec/httpGet/tcpSocket)
- 安全上下文: runAsUser/runAsGroup/privileged/capabilities等
- 卷挂载: name/mountPath/subPath/readOnly
- Pod级镜像拉取凭证(imagePullSecrets)编辑
- DeploymentWorkload.tsx: 容器Tab从旧DeploymentEdit替换为DeploymentEditContainers
- 纯前端工程, 后端GetYAML/UpdateYAML已完善
- 验证: tsc零错误 + vite build成功 + SPA路由200 + YAML PUT保存200
Closes M7
1018 lines
49 KiB
TypeScript
1018 lines
49 KiB
TypeScript
// DeploymentEditContainers.tsx — 编辑器「容器信息」Tab (M7)
|
||
// Kuboard 风格容器编辑器:工作容器 + 初始化容器完整编辑
|
||
// 编辑字段:镜像/拉取策略/命令/参数/环境变量/资源/端口/健康检查/生命周期/安全上下文/卷挂载
|
||
// 作者: Logan <admin@attacker.club>
|
||
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<any>(manifest || {});
|
||
const [containers, setContainers] = useState<ContainerForm[]>([]);
|
||
const [initContainers, setInitContainers] = useState<ContainerForm[]>([]);
|
||
const [imagePullSecrets, setImagePullSecrets] = useState<string[]>([]);
|
||
|
||
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<ContainerForm>) => {
|
||
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<ContainerForm>) => {
|
||
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 (
|
||
<Card className="bg-slate-900 border-slate-800">
|
||
<CardContent className="p-12 text-center">
|
||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-slate-500" />
|
||
<p className="text-slate-500 mt-3 text-sm">加载中...</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
if (!manifest) {
|
||
return (
|
||
<Card className="bg-slate-900 border-slate-800">
|
||
<CardContent className="p-8 text-center text-slate-500">
|
||
未获取到 manifest 数据,请使用 YAML 编辑模式直接修改。
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Pod 级别设置:镜像拉取凭证 */}
|
||
<Card className="bg-slate-900 border-slate-800">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
|
||
<KeyRound className="h-4 w-4 text-amber-400" />
|
||
镜像拉取凭证 (imagePullSecrets)
|
||
<Badge variant="outline" className="border-slate-700 text-slate-500 text-[10px]">Pod 级别</Badge>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 space-y-2">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{imagePullSecrets.map((sec, i) => (
|
||
<div key={i} className="flex items-center gap-1 bg-slate-800 rounded px-2 py-1">
|
||
<span className="text-xs text-slate-300 font-mono">{sec}</span>
|
||
<button onClick={() => setImagePullSecrets(prev => prev.filter((_, j) => j !== i))}
|
||
className="text-red-400 hover:text-red-300 ml-1"><Trash2 className="h-3 w-3" /></button>
|
||
</div>
|
||
))}
|
||
{imagePullSecrets.length === 0 && <span className="text-[11px] text-slate-600">无</span>}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Input placeholder="输入 Secret 名称后回车" className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-8"
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
const val = (e.target as HTMLInputElement).value.trim();
|
||
if (val) { setImagePullSecrets(prev => [...prev, val]); (e.target as HTMLInputElement).value = ''; }
|
||
}
|
||
}} />
|
||
<Button size="sm" variant="outline" className="h-8 border-slate-700 text-slate-300 text-xs"
|
||
onClick={() => setImagePullSecrets(prev => [...prev, ''])}>
|
||
<Plus className="h-3 w-3 mr-1" />新增
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 初始化容器 */}
|
||
<ContainerGroupSection
|
||
title="初始化容器"
|
||
description="在主容器启动前按顺序执行的容器,用于初始化环境"
|
||
icon={<Play className="h-4 w-4 text-purple-400" />}
|
||
containers={initContainers}
|
||
accentColor="purple"
|
||
onAdd={addInit}
|
||
onRemove={removeInit}
|
||
onUpdate={updateInit}
|
||
/>
|
||
|
||
{/* 工作容器 */}
|
||
<ContainerGroupSection
|
||
title="工作容器"
|
||
description="Deployment 运行的主容器,定义应用进程及其运行时配置"
|
||
icon={<Container className="h-4 w-4 text-blue-400" />}
|
||
containers={containers}
|
||
accentColor="blue"
|
||
onAdd={addContainer}
|
||
onRemove={removeContainer}
|
||
onUpdate={updateContainer}
|
||
onDuplicate={duplicateContainer}
|
||
/>
|
||
|
||
{/* 保存栏 */}
|
||
<div className="sticky bottom-0 bg-slate-950/80 backdrop-blur border-t border-slate-800 p-3 flex items-center justify-between">
|
||
<div className="text-[11px] text-slate-500">
|
||
共 <span className="text-slate-300 font-semibold">{initContainers.length}</span> 个初始化容器 +
|
||
<span className="text-slate-300 font-semibold ml-1">{containers.length}</span> 个工作容器
|
||
</div>
|
||
<div className="flex gap-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" />}
|
||
保存
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============ 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<ContainerForm>) => void;
|
||
onDuplicate?: (idx: number) => void;
|
||
}) {
|
||
const [openIndices, setOpenIndices] = useState<Set<number>>(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 (
|
||
<Card className={`bg-slate-900 border-slate-800`}>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
{icon}
|
||
<CardTitle className="text-sm text-slate-200">{title}</CardTitle>
|
||
<Badge variant="outline" className={`border-slate-700 text-slate-400 text-[10px]`}>{containers.length}</Badge>
|
||
</div>
|
||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={onAdd}>
|
||
<Plus className="h-3 w-3 mr-1" />新增容器
|
||
</Button>
|
||
</div>
|
||
<p className="text-[11px] text-slate-600">{description}</p>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 space-y-3">
|
||
{containers.length === 0 && (
|
||
<div className="text-center py-6 text-slate-600 text-xs">暂无容器,点击「新增容器」添加</div>
|
||
)}
|
||
{containers.map((c, idx) => {
|
||
const isOpen = openIndices.has(idx) || containers.length === 1;
|
||
return (
|
||
<div key={idx} className={`rounded-lg border ${borderClass} overflow-hidden`}>
|
||
{/* container header */}
|
||
<div className={`flex items-center justify-between px-3 py-2 ${headerBg} cursor-pointer`} onClick={() => toggleOpen(idx)}>
|
||
<div className="flex items-center gap-2">
|
||
{isOpen ? <ChevronDown className="h-4 w-4 text-slate-500" /> : <ChevronRight className="h-4 w-4 text-slate-500" />}
|
||
<Container className={`h-4 w-4 ${accentColor === 'blue' ? 'text-blue-400' : 'text-purple-400'}`} />
|
||
<span className="text-sm font-mono text-slate-200 font-semibold">{c.name || '(未命名)'}</span>
|
||
{c.image && <code className="text-[10px] text-slate-500 font-mono truncate max-w-[300px]">{c.image}</code>}
|
||
</div>
|
||
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
|
||
{onDuplicate && (
|
||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-500 hover:text-slate-300" title="复制"
|
||
onClick={() => onDuplicate(idx)}>
|
||
<RotateCw className="h-3.5 w-3.5" />
|
||
</Button>
|
||
)}
|
||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" title="删除"
|
||
onClick={() => { if (confirm(`确认删除容器「${c.name}」?`)) onRemove(idx); }}>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{/* container body */}
|
||
{isOpen && (
|
||
<div className="p-3 space-y-4 bg-slate-950/30">
|
||
<ContainerEditor container={c} onUpdate={(patch) => onUpdate(idx, patch)} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// ============ Single Container Editor (all sections) ============
|
||
|
||
function ContainerEditor({ container, onUpdate }: {
|
||
container: ContainerForm;
|
||
onUpdate: (patch: Partial<ContainerForm>) => void;
|
||
}) {
|
||
const c = container;
|
||
const sectionBtn = "flex items-center gap-1.5 text-xs font-semibold text-slate-300 mb-2";
|
||
|
||
return (
|
||
<>
|
||
{/* ---- 基本设置 ---- */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div>
|
||
<Label className="text-slate-400 text-xs">容器名称</Label>
|
||
<Input value={c.name} onChange={e => 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" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-slate-400 text-xs">容器镜像</Label>
|
||
<Input value={c.image} onChange={e => 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" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-slate-400 text-xs">镜像拉取策略</Label>
|
||
<Select value={c.imagePullPolicy || '__none'} onValueChange={v => onUpdate({ imagePullPolicy: v === '__none' ? '' : v })}>
|
||
<SelectTrigger className="mt-1 bg-slate-800 border-slate-700 text-slate-200 text-xs h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-slate-900 border-slate-700">
|
||
<SelectItem value="__none" className="text-slate-400">不设置(默认 IfNotPresent)</SelectItem>
|
||
<SelectItem value="Always" className="text-slate-200">Always</SelectItem>
|
||
<SelectItem value="IfNotPresent" className="text-slate-200">IfNotPresent</SelectItem>
|
||
<SelectItem value="Never" className="text-slate-200">Never</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label className="text-slate-400 text-xs">工作目录 (workingDir)</Label>
|
||
<Input value={c.workingDir || ''} onChange={e => 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" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* ---- 命令与参数 ---- */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div>
|
||
<Label className="text-slate-400 text-xs">启动命令 (command) — 每行一个</Label>
|
||
<Textarea value={arrToText(c.command)} placeholder={"/bin/sh\n-c"}
|
||
onChange={e => onUpdate({ command: textToArr(e.target.value) })}
|
||
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[60px]" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-slate-400 text-xs">启动参数 (args) — 每行一个</Label>
|
||
<Textarea value={arrToText(c.args)} placeholder={"--config=/etc/app.yaml\n--debug"}
|
||
onChange={e => onUpdate({ args: textToArr(e.target.value) })}
|
||
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[60px]" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* ---- 环境变量 ---- */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className={sectionBtn}><Terminal className="h-3.5 w-3.5 text-cyan-400" /> 环境变量</div>
|
||
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
|
||
onClick={() => onUpdate({ env: [...c.env, { name: '', value: '' }] })}>
|
||
<Plus className="h-3 w-3 mr-1" />新增
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
{c.env.map((env, ei) => (
|
||
<div key={ei} className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
|
||
<Input value={env.name} placeholder="KEY"
|
||
onChange={e => { const n = [...c.env]; n[ei] = { ...env, name: e.target.value }; onUpdate({ env: n }); }}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
<Input value={env.value || ''} placeholder="value"
|
||
onChange={e => { const n = [...c.env]; n[ei] = { ...env, value: e.target.value }; onUpdate({ env: n }); }}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400"
|
||
onClick={() => onUpdate({ env: c.env.filter((_, j) => j !== ei) })}>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{c.env.length === 0 && <p className="text-[11px] text-slate-600">无环境变量</p>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ---- 端口 ---- */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className={sectionBtn}><Network className="h-3.5 w-3.5 text-indigo-400" /> 端口</div>
|
||
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
|
||
onClick={() => onUpdate({ ports: [...c.ports, { name: '', containerPort: '', protocol: 'TCP' }] })}>
|
||
<Plus className="h-3 w-3 mr-1" />新增
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
{c.ports.map((p, pi) => (
|
||
<div key={pi} className="grid grid-cols-[1fr_1fr_100px_auto] gap-2">
|
||
<Input value={p.name || ''} placeholder="http"
|
||
onChange={e => { const n = [...c.ports]; n[pi] = { ...p, name: e.target.value }; onUpdate({ ports: n }); }}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
<Input type="number" value={p.containerPort} placeholder="8080"
|
||
onChange={e => { const n = [...c.ports]; n[pi] = { ...p, containerPort: e.target.value }; onUpdate({ ports: n }); }}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
<Select value={p.protocol || 'TCP'} onValueChange={v => { const n = [...c.ports]; n[pi] = { ...p, protocol: v }; onUpdate({ ports: n }); }}>
|
||
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-slate-900 border-slate-700">
|
||
<SelectItem value="TCP" className="text-slate-200">TCP</SelectItem>
|
||
<SelectItem value="UDP" className="text-slate-200">UDP</SelectItem>
|
||
<SelectItem value="SCTP" className="text-slate-200">SCTP</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400"
|
||
onClick={() => onUpdate({ ports: c.ports.filter((_, j) => j !== pi) })}>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{c.ports.length === 0 && <p className="text-[11px] text-slate-600">无端口暴露</p>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ---- 资源 requests/limits ---- */}
|
||
<div>
|
||
<div className={sectionBtn}><Cpu className="h-3.5 w-3.5 text-amber-400" /> 资源 (requests / limits)</div>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||
<div>
|
||
<Label className="text-[11px] text-slate-500">CPU 请求</Label>
|
||
<Input value={c.resources.requests?.cpu || ''} placeholder="100m"
|
||
onChange={e => onUpdate({ resources: { ...c.resources, requests: { ...c.resources.requests, cpu: e.target.value } } })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[11px] text-slate-500">CPU 上限</Label>
|
||
<Input value={c.resources.limits?.cpu || ''} placeholder="500m"
|
||
onChange={e => onUpdate({ resources: { ...c.resources, limits: { ...c.resources.limits, cpu: e.target.value } } })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[11px] text-slate-500">内存请求</Label>
|
||
<Input value={c.resources.requests?.memory || ''} placeholder="128Mi"
|
||
onChange={e => onUpdate({ resources: { ...c.resources, requests: { ...c.resources.requests, memory: e.target.value } } })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[11px] text-slate-500">内存上限</Label>
|
||
<Input value={c.resources.limits?.memory || ''} placeholder="512Mi"
|
||
onChange={e => onUpdate({ resources: { ...c.resources, limits: { ...c.resources.limits, memory: e.target.value } } })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ---- 健康检查 ---- */}
|
||
<div>
|
||
<div className={sectionBtn}><Heart className="h-3.5 w-3.5 text-rose-400" /> 健康检查 (Probes)</div>
|
||
<div className="space-y-2">
|
||
{(['livenessProbe', 'readinessProbe', 'startupProbe'] as const).map(pt => (
|
||
<ProbeEditor key={pt}
|
||
label={{ livenessProbe: '存活探针 (liveness)', readinessProbe: '就绪探针 (readiness)', startupProbe: '启动探针 (startup)' }[pt]}
|
||
probe={c[pt]}
|
||
onChange={(p) => onUpdate({ [pt]: p } as any)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ---- 生命周期 ---- */}
|
||
<div>
|
||
<div className={sectionBtn}><Zap className="h-3.5 w-3.5 text-yellow-400" /> 生命周期回调 (Lifecycle)</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<LifecycleHandlerEditor label="启动后 (postStart)" handler={c.lifecycle?.postStart}
|
||
onChange={h => onUpdate({ lifecycle: { ...c.lifecycle, postStart: h } })} />
|
||
<LifecycleHandlerEditor label="终止前 (preStop)" handler={c.lifecycle?.preStop}
|
||
onChange={h => onUpdate({ lifecycle: { ...c.lifecycle, preStop: h } })} />
|
||
</div>
|
||
{!c.lifecycle && (
|
||
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px] mt-1"
|
||
onClick={() => onUpdate({ lifecycle: { postStart: { type: 'none' }, preStop: { type: 'none' } } })}>
|
||
<Plus className="h-3 w-3 mr-1" />启用生命周期回调
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ---- 安全上下文 ---- */}
|
||
<div>
|
||
<div className={sectionBtn}><Shield className="h-3.5 w-3.5 text-emerald-400" /> 安全上下文 (SecurityContext)</div>
|
||
{c.securityContext ? (
|
||
<div className="space-y-2 border border-slate-800 rounded-md p-3 bg-slate-950/30">
|
||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||
<div>
|
||
<Label className="text-[11px] text-slate-500">runAsUser</Label>
|
||
<Input type="number" value={c.securityContext.runAsUser ?? ''} placeholder="1000"
|
||
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, runAsUser: e.target.value === '' ? undefined : Number(e.target.value) } })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[11px] text-slate-500">runAsGroup</Label>
|
||
<Input type="number" value={c.securityContext.runAsGroup ?? ''} placeholder="1000"
|
||
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, runAsGroup: e.target.value === '' ? undefined : Number(e.target.value) } })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[11px] text-slate-500">Capabilities Add (逗号分隔)</Label>
|
||
<Input value={(c.securityContext.capabilitiesAdd || []).join(',')} placeholder="NET_ADMIN"
|
||
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, capabilitiesAdd: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[11px] text-slate-500">Capabilities Drop (逗号分隔)</Label>
|
||
<Input value={(c.securityContext.capabilitiesDrop || []).join(',')} placeholder="ALL"
|
||
onChange={e => onUpdate({ securityContext: { ...c.securityContext!, capabilitiesDrop: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4 flex-wrap">
|
||
<BoolToggle label="runAsNonRoot" value={!!c.securityContext.runAsNonRoot}
|
||
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, runAsNonRoot: v } })} />
|
||
<BoolToggle label="privileged" value={!!c.securityContext.privileged}
|
||
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, privileged: v } })} />
|
||
<BoolToggle label="readOnlyRootFilesystem" value={!!c.securityContext.readOnlyRootFilesystem}
|
||
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, readOnlyRootFilesystem: v } })} />
|
||
<BoolToggle label="allowPrivilegeEscalation" value={c.securityContext.allowPrivilegeEscalation ?? false}
|
||
onChange={v => onUpdate({ securityContext: { ...c.securityContext!, allowPrivilegeEscalation: v } })} />
|
||
</div>
|
||
<Button size="sm" variant="ghost" className="h-6 text-red-400 text-[11px]"
|
||
onClick={() => onUpdate({ securityContext: null })}>
|
||
<Trash2 className="h-3 w-3 mr-1" />移除安全上下文
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
|
||
onClick={() => onUpdate({ securityContext: { runAsNonRoot: false, privileged: false } })}>
|
||
<Plus className="h-3 w-3 mr-1" />启用安全上下文
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ---- 卷挂载 ---- */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className={sectionBtn}><FolderOpen className="h-3.5 w-3.5 text-teal-400" /> 卷挂载 (VolumeMounts)</div>
|
||
<Button size="sm" variant="outline" className="h-6 border-slate-700 text-slate-300 text-[11px]"
|
||
onClick={() => onUpdate({ volumeMounts: [...c.volumeMounts, { name: '', mountPath: '', subPath: '', readOnly: false }] })}>
|
||
<Plus className="h-3 w-3 mr-1" />新增
|
||
</Button>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
{c.volumeMounts.map((m, mi) => (
|
||
<div key={mi} className="grid grid-cols-[1fr_1fr_1fr_60px_auto] gap-2 items-center">
|
||
<Input value={m.name} placeholder="volume 名称"
|
||
onChange={e => { const n = [...c.volumeMounts]; n[mi] = { ...m, name: e.target.value }; onUpdate({ volumeMounts: n }); }}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
<Input value={m.mountPath} placeholder="/data"
|
||
onChange={e => { const n = [...c.volumeMounts]; n[mi] = { ...m, mountPath: e.target.value }; onUpdate({ volumeMounts: n }); }}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
<Input value={m.subPath || ''} placeholder="subPath (可选)"
|
||
onChange={e => { const n = [...c.volumeMounts]; n[mi] = { ...m, subPath: e.target.value }; onUpdate({ volumeMounts: n }); }}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
|
||
<label className="flex items-center gap-1 text-[10px] text-slate-500 cursor-pointer">
|
||
<Switch checked={!!m.readOnly}
|
||
onCheckedChange={v => { const n = [...c.volumeMounts]; n[mi] = { ...m, readOnly: v }; onUpdate({ volumeMounts: n }); }} />
|
||
RO
|
||
</label>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-400"
|
||
onClick={() => onUpdate({ volumeMounts: c.volumeMounts.filter((_, j) => j !== mi) })}>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{c.volumeMounts.length === 0 && <p className="text-[11px] text-slate-600">无卷挂载</p>}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ============ Probe Editor ============
|
||
|
||
function ProbeEditor({ label, probe, onChange }: {
|
||
label: string;
|
||
probe: ProbeDef | null | undefined;
|
||
onChange: (p: ProbeDef | null) => void;
|
||
}) {
|
||
const enabled = !!probe;
|
||
const p = probe || { type: 'httpGet' as const };
|
||
|
||
const setField = (patch: Partial<ProbeDef>) => onChange({ ...p, ...patch });
|
||
|
||
return (
|
||
<div className="border border-slate-800 rounded-md p-2.5 bg-slate-950/30">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-xs text-slate-300 font-medium">{label}</span>
|
||
<Switch checked={enabled}
|
||
onCheckedChange={v => onChange(v ? { type: 'httpGet', httpPath: '/', httpPort: 8080, initialDelaySeconds: 10, periodSeconds: 10 } : null)} />
|
||
</div>
|
||
{enabled && (
|
||
<>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-[10px] text-slate-500 w-8">类型</span>
|
||
<Select value={p.type || 'httpGet'} onValueChange={v => setField({ type: v as ProbeDef['type'] })}>
|
||
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 flex-1">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-slate-900 border-slate-700">
|
||
<SelectItem value="httpGet" className="text-slate-200">HTTP GET</SelectItem>
|
||
<SelectItem value="tcpSocket" className="text-slate-200">TCP Socket</SelectItem>
|
||
<SelectItem value="exec" className="text-slate-200">Exec 命令</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{p.type === 'httpGet' && (
|
||
<div className="grid grid-cols-3 gap-2 mb-2">
|
||
<Input value={p.httpPath || ''} placeholder="/healthz"
|
||
onChange={e => setField({ httpPath: e.target.value })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
<Input type="number" value={p.httpPort ?? ''} placeholder="8080"
|
||
onChange={e => setField({ httpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
<Input value={p.httpHost || ''} placeholder="host (可选)"
|
||
onChange={e => setField({ httpHost: e.target.value })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
</div>
|
||
)}
|
||
{p.type === 'tcpSocket' && (
|
||
<div className="mb-2">
|
||
<Input type="number" value={p.tcpPort ?? ''} placeholder="端口"
|
||
onChange={e => setField({ tcpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
</div>
|
||
)}
|
||
{p.type === 'exec' && (
|
||
<div className="mb-2">
|
||
<Textarea value={p.execCommand || ''} placeholder="每行一个命令片段,如:\ncat\n/tmp/healthy"
|
||
onChange={e => setField({ execCommand: e.target.value })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[50px]" />
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-5 gap-2">
|
||
<div>
|
||
<Label className="text-[10px] text-slate-500">延迟(s)</Label>
|
||
<Input type="number" value={p.initialDelaySeconds ?? ''} placeholder="10"
|
||
onChange={e => setField({ initialDelaySeconds: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-slate-500">间隔(s)</Label>
|
||
<Input type="number" value={p.periodSeconds ?? ''} placeholder="10"
|
||
onChange={e => setField({ periodSeconds: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-slate-500">超时(s)</Label>
|
||
<Input type="number" value={p.timeoutSeconds ?? ''} placeholder="1"
|
||
onChange={e => setField({ timeoutSeconds: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-slate-500">成功</Label>
|
||
<Input type="number" value={p.successThreshold ?? ''} placeholder="1"
|
||
onChange={e => setField({ successThreshold: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
</div>
|
||
<div>
|
||
<Label className="text-[10px] text-slate-500">失败</Label>
|
||
<Input type="number" value={p.failureThreshold ?? ''} placeholder="3"
|
||
onChange={e => setField({ failureThreshold: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============ Lifecycle Handler Editor ============
|
||
|
||
function LifecycleHandlerEditor({ label, handler, onChange }: {
|
||
label: string;
|
||
handler: LifecycleHandler | undefined;
|
||
onChange: (h: LifecycleHandler) => void;
|
||
}) {
|
||
const h = handler || { type: 'none' as const };
|
||
const selVal = (!h.type || h.type === 'none') ? '__none' : h.type;
|
||
|
||
return (
|
||
<div className="border border-slate-800 rounded-md p-2.5 bg-slate-950/30">
|
||
<span className="text-xs text-slate-300 font-medium block mb-2">{label}</span>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="text-[10px] text-slate-500 w-8">类型</span>
|
||
<Select value={selVal} onValueChange={v => onChange({ ...h, type: (v === '__none' ? 'none' : v) as LifecycleHandler['type'] })}>
|
||
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 flex-1">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-slate-900 border-slate-700">
|
||
<SelectItem value="__none" className="text-slate-400">不设置</SelectItem>
|
||
<SelectItem value="exec" className="text-slate-200">Exec 命令</SelectItem>
|
||
<SelectItem value="httpGet" className="text-slate-200">HTTP GET</SelectItem>
|
||
<SelectItem value="tcpSocket" className="text-slate-200">TCP Socket</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{h.type === 'exec' && (
|
||
<Textarea value={h.execCommand || ''} placeholder="每行一个命令片段"
|
||
onChange={e => onChange({ ...h, execCommand: e.target.value })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs min-h-[50px]" />
|
||
)}
|
||
{h.type === 'httpGet' && (
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Input value={h.httpPath || ''} placeholder="/shutdown"
|
||
onChange={e => onChange({ ...h, httpPath: e.target.value })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
<Input type="number" value={h.httpPort ?? ''} placeholder="8080"
|
||
onChange={e => onChange({ ...h, httpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
</div>
|
||
)}
|
||
{h.type === 'tcpSocket' && (
|
||
<Input type="number" value={h.tcpPort ?? ''} placeholder="端口"
|
||
onChange={e => onChange({ ...h, tcpPort: e.target.value === '' ? undefined : Number(e.target.value) })}
|
||
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-7" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============ Bool Toggle helper ============
|
||
|
||
function BoolToggle({ label, value, onChange }: {
|
||
label: string;
|
||
value: boolean;
|
||
onChange: (v: boolean) => void;
|
||
}) {
|
||
return (
|
||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||
<Switch checked={value} onCheckedChange={onChange} />
|
||
<span className="text-[11px] text-slate-400 font-mono">{label}</span>
|
||
</label>
|
||
);
|
||
}
|