Files
aiops/frontend/src/components/kubernetes/DeploymentEditContainers.tsx
T
attacker a8b4af4409 feat(k8s): M7 容器信息Tab — 完整容器编辑器(工作容器+初始化容器)
- 新增 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
2026-06-22 23:29:30 +00:00

1018 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}