refactor(frontend): API 子模块化、大组件拆分与 TanStack Query hooks

P1: kubernetes/system API 拆为子目录模块,导出工具下沉到 lib/export-utils,
    http 统一 buildApiUrl/getWsBase;HostMonitor、DeploymentEdit* 拆至子组件。

P2: 新增 hooks/api(queryKeys、useTerminalSessions、useHostMonitor),
    TerminalSessionsTab 与 HostMonitor 接入 TanStack Query。

P3: 保留 services/api 路径,未迁移至 api/ 目录。
This commit is contained in:
Logan.Li committed 2026-06-26 00:02:29 +08:00
1 parent 7fb2236b4d
commit 0b404b4611
226 files changed
+3040 -2596

No files matched your search

@@ -1,4 +1,4 @@
// DeploymentEditAdvanced.tsx — 编辑器「高级设置」Tab (M8)
// DeploymentEditAdvanced.tsx — 编辑器「高级设置」Tab (M8) // Logan.Li
// 编辑内容:更新策略 / 历史版本 / 调度(节点选择/容忍) / DNS / 安全上下文 / 终止宽限期 / 调度器 / SA
// 作者: Logan <admin@attacker.club>
import { useState, useEffect } from 'react';
@@ -7,10 +7,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Save, Loader2, Settings, RefreshCw, Server, Shield, Clock, Network, Plus, Trash2, Globe, MapPin,
Save, Loader2, Settings, RefreshCw, Shield, Clock, Network,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/services/api';
import { SchedulingSection, type ScheduleMode } from './deployment-edit-advanced/SchedulingSection';
import { DnsHostSection } from './deployment-edit-advanced/DnsHostSection';
interface Props {
clusterCode: string;
@@ -45,13 +47,11 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
const [progressDeadlineSeconds, setProgressDeadlineSeconds] = useState('600');
// ──── 调度策略 ────
const [scheduleMode, setScheduleMode] = useState<'nodeName' | 'nodeSelector' | 'nodeAffinity'>('nodeSelector');
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>('nodeSelector');
const [nodeName, setNodeName] = useState('');
const [availableNodes, setAvailableNodes] = useState<Array<{ name: string; ip: string; labels: Record<string, string> }>>([]);
const [nodesLoading, setNodesLoading] = useState(false);
// 标签选择器 — key=value 列表(替代原 nodeSelectorKv)
const [labelSelectorKv, setLabelSelectorKv] = useState<Array<{ key: string; value: string }>>([]);
// 预置标签(从节点聚合)
const [nodeLabels, setNodeLabels] = useState<Record<string, string[]>>({});
const [tolerations, setTolerations] = useState<Toleration[]>([]);
const [dnsPolicy, setDnsPolicy] = useState('ClusterFirst');
@@ -118,13 +118,13 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
// 加载节点列表
if (clusterCode) {
setNodesLoading(true);
kubernetesApi.getClusterNodes(clusterCode).then(( res: any) => {
kubernetesApi.getClusterNodes(clusterCode).then((res: any) => {
const nodes: Record<string, any>[] = Array.isArray(res) ? res : (res?.items || res?.list || []);
const mapped = nodes.map(( n: any) => {
const mapped = nodes.map((n: any) => {
const meta = n.metadata || {};
const status = n.status || {};
const addresses: Record<string, any>[] = Array.isArray(status.addresses) ? status.addresses : [];
const internalIP = addresses.find(( a: any) => a.type === 'InternalIP')?.address || '';
const internalIP = addresses.find((a: any) => a.type === 'InternalIP')?.address || '';
return { name: meta.name || '', ip: internalIP, labels: meta.labels || {} };
});
setAvailableNodes(mapped);
@@ -193,12 +193,6 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
const updateNs = (i: number, patch: Partial<{ key: string; value: string }>) =>
setLabelSelectorKv(prev => prev.map((n, idx) => (idx === i ? { ...n, ...patch } : n)));
// tolerations 操作
const _addTol = () => setTolerations(prev => [...prev, { key: '', operator: 'Equal', value: '', effect: 'NoSchedule', tolerationSeconds: '' }]);
const _removeTol = (i: number) => setTolerations(prev => prev.filter((_, idx) => idx !== i));
const _updateTol = (i: number, patch: Partial<Toleration>) =>
setTolerations(prev => prev.map((t, idx) => (idx === i ? { ...t, ...patch } : t)));
const save = async () => {
setSaving(true);
try {
@@ -404,137 +398,19 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
</CardContent>
</Card>
{/* 调度:节点名称 / 标签选择器 / 节点亲和性 */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Server className="h-4 w-4 text-emerald-400" />调度 (Scheduling)
</CardTitle>
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-0.5 border border-slate-700">
{([
{ key: 'nodeName' as const, label: '节点名称', desc: '指定节点' },
{ key: 'nodeSelector' as const, label: '标签选择器', desc: 'key=value' },
{ key: 'nodeAffinity' as const, label: '节点亲和性', desc: '高级规则' },
]).map(m => (
<button key={m.key} onClick={() => setScheduleMode(m.key)}
className={`px-2.5 py-1 rounded text-[10px] font-medium transition-all ${
scheduleMode === m.key
? 'bg-emerald-500/20 text-emerald-300 shadow-sm'
: 'text-slate-400 hover:text-slate-300'
}`}>
{m.label}
</button>
))}
</div>
</div>
<p className="text-xs text-slate-500 mt-1">
{scheduleMode === 'nodeName' && 'Pod 将直接调度到指定节点'}
{scheduleMode === 'nodeSelector' && 'Pod 将调度到匹配标签的节点'}
{scheduleMode === 'nodeAffinity' && '通过亲和性规则控制 Pod 调度到指定节点'}
</p>
</CardHeader>
<CardContent className="pt-0">
{/* 节点名称模式 */}
{scheduleMode === 'nodeName' && (
<div className="space-y-2">
<Label className="text-slate-400 text-xs">请选择节点</Label>
{nodesLoading ? (
<div className="flex items-center gap-1 text-slate-500 text-xs"><Loader2 className="h-3 w-3 animate-spin" />加载节点列表...</div>
) : (
<select value={nodeName} onChange={e => setNodeName(e.target.value)}
className="h-8 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2">
<option value="">(不指定)</option>
{availableNodes.map(n => (
<option key={n.name} value={n.name}>{n.name} {n.ip ? `(${n.ip})` : ''}</option>
))}
</select>
)}
{nodeName && (
<div className="text-[10px] text-slate-500">
已选择: <code className="bg-slate-800 px-1 py-0.5 rounded text-emerald-400">{nodeName}</code>
</div>
)}
</div>
)}
{/* 标签选择器模式 */}
{scheduleMode === 'nodeSelector' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-slate-400 text-xs">请选择标签</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={addNs}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{labelSelectorKv.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置标签选择器</p>
) : labelSelectorKv.map((n, i) => (
<div key={i} className="flex items-center gap-2">
{/* 标签 Key 下拉 */}
<select value={n.key} onChange={e => updateNs(i, { key: e.target.value, value: '' })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5">
<option value="">选择标签</option>
{Object.entries(nodeLabels).map(([k, vals]) => (
<option key={k} value={k}>{k} ({vals.length})</option>
))}
</select>
{/* 标签 Value 下拉 */}
<select value={n.value} onChange={e => updateNs(i, { value: e.target.value })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
disabled={!n.key}>
<option value="">选择值</option>
{(nodeLabels[n.key] || []).map(v => (
<option key={v} value={v}>{v}</option>
))}
</select>
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400" onClick={() => removeNs(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* 节点亲和性模式 */}
{scheduleMode === 'nodeAffinity' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-slate-400 text-xs">
请选择标签 <span className="text-slate-600">(生成 requiredDuringScheduling 规则)</span>
</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={addNs}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{labelSelectorKv.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置亲和性规则</p>
) : labelSelectorKv.map((n, i) => (
<div key={i} className="flex items-center gap-2">
<select value={n.key} onChange={e => updateNs(i, { key: e.target.value, value: '' })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5">
<option value="">选择标签</option>
{Object.entries(nodeLabels).map(([k, vals]) => (
<option key={k} value={k}>{k} ({vals.length})</option>
))}
</select>
<select value={n.value} onChange={e => updateNs(i, { value: e.target.value })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
disabled={!n.key}>
<option value="">选择值</option>
{(nodeLabels[n.key] || []).map(v => (
<option key={v} value={v}>{v}</option>
))}
</select>
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400" onClick={() => removeNs(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<SchedulingSection
scheduleMode={scheduleMode}
onScheduleModeChange={setScheduleMode}
nodeName={nodeName}
onNodeNameChange={setNodeName}
availableNodes={availableNodes}
nodesLoading={nodesLoading}
labelSelectorKv={labelSelectorKv}
onAddLabelSelector={addNs}
onRemoveLabelSelector={removeNs}
onUpdateLabelSelector={updateNs}
nodeLabels={nodeLabels}
/>
{/* Pod 级别杂项 */}
<Card className="bg-slate-900 border-slate-800">
@@ -645,92 +521,16 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
</CardContent>
</Card>
{/* DNS 配置(M8 补全) */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Globe className="h-4 w-4 text-indigo-400" />DNS 配置 (dnsConfig)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3">
<p className="text-[11px] text-slate-500">
提示:dnsConfig 通常与 <code className="text-slate-400">dnsPolicy: None</code> 搭配使用。每行一个值。
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-slate-500 text-xs">nameservers(每行一个 IP)</Label>
<textarea value={dnsNameservers} onChange={e => setDnsNameservers(e.target.value)}
placeholder={'8.8.8.8\n114.114.114.114'}
rows={3}
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 font-mono p-2 resize-y" />
</div>
<div>
<Label className="text-slate-500 text-xs">searches(搜索域,每行一个)</Label>
<textarea value={dnsSearches} onChange={e => setDnsSearches(e.target.value)}
placeholder={'svc.cluster.local\ncluster.local'}
rows={3}
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 font-mono p-2 resize-y" />
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<Label className="text-slate-400 text-xs">options(DNS 选项)</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400"
onClick={() => setDnsOptions(prev => [...prev, { name: '', value: '' }])}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{dnsOptions.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置</p>
) : dnsOptions.map((o, i) => (
<div key={i} className="flex items-center gap-2 mb-1.5">
<Input value={o.name} onChange={e => setDnsOptions(prev => prev.map((x, idx) => idx === i ? { ...x, name: e.target.value } : x))}
placeholder="ndots" className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<span className="text-slate-600">:</span>
<Input value={o.value} onChange={e => setDnsOptions(prev => prev.map((x, idx) => idx === i ? { ...x, value: e.target.value } : x))}
placeholder="5(可留空)" className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400"
onClick={() => setDnsOptions(prev => prev.filter((_, idx) => idx !== i))}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
{/* 主机别名(M8 补全) */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<MapPin className="h-4 w-4 text-amber-400" />主机别名 (hostAliases)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<div className="flex items-center justify-between mb-1">
<Label className="text-slate-400 text-xs">IP → 主机名映射(注入 Pod 的 /etc/hosts)</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400"
onClick={() => setHostAliases(prev => [...prev, { ip: '', hostnames: '' }])}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{hostAliases.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置</p>
) : hostAliases.map((a, i) => (
<div key={i} className="flex items-center gap-2">
<Input value={a.ip} onChange={e => setHostAliases(prev => prev.map((x, idx) => idx === i ? { ...x, ip: e.target.value } : x))}
placeholder="10.10.10.72" className="w-40 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<span className="text-slate-600">→</span>
<Input value={a.hostnames} onChange={e => setHostAliases(prev => prev.map((x, idx) => idx === i ? { ...x, hostnames: e.target.value } : x))}
placeholder="gitlab.local, registry.local" className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400"
onClick={() => setHostAliases(prev => prev.filter((_, idx) => idx !== i))}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</CardContent>
</Card>
<DnsHostSection
dnsNameservers={dnsNameservers}
onDnsNameserversChange={setDnsNameservers}
dnsSearches={dnsSearches}
onDnsSearchesChange={setDnsSearches}
dnsOptions={dnsOptions}
onDnsOptionsChange={setDnsOptions}
hostAliases={hostAliases}
onHostAliasesChange={setHostAliases}
/>
{/* 操作栏 */}
<div className="flex justify-end gap-2 pt-2">
@@ -742,4 +542,4 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
</div>
</div>
);
}
}
@@ -2,316 +2,29 @@
// Kuboard 风格容器编辑器:工作容器 + 初始化容器完整编辑
// 编辑字段:镜像/拉取策略/命令/参数/环境变量/资源/端口/健康检查/生命周期/安全上下文/卷挂载
// 作者: Logan <admin@attacker.club>
import { useState, useEffect} from 'react';
import { useState, useEffect } 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 {
Plus,
Trash2,
Save,
Loader2,
Container,
Cpu,
Heart,
FolderOpen,
ChevronDown,
ChevronRight,
Terminal,
Shield,
Zap,
Network,
KeyRound,
Play,
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: Record<string, 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: Record<string, 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: Record<string, 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: Record<string, 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: Record<string, 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: Record<string, 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: Record<string, 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: Record<string, 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: Record<string, 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 ============
import { ContainerGroupSection } from './deployment-edit/ContainerGroupSection';
import {
toContainerForm, emptyContainer, containerToK8s,
} from './deployment-edit/container-utils';
import type { ContainerForm, DeploymentEditContainersProps } from './deployment-edit/types';
export function DeploymentEditContainers({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: DeploymentEditContainersProps) {
const { toast } = useToast();
@@ -328,11 +41,11 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
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 || ''));
setImagePullSecrets((Array.isArray(podSpec.imagePullSecrets) ? podSpec.imagePullSecrets : []).map((s: any) => s.name || ''));
setLoading(false);
}, [manifest]);
// ---- container list ops ----
// ---- container list ops ---- // Logan.Li
const updateContainer = (idx: number, patch: Partial<ContainerForm>) => {
setContainers(prev => prev.map((c, i) => (i === idx ? { ...c, ...patch } : c)));
};
@@ -362,7 +75,7 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
setInitContainers(prev => prev.filter((_, i) => i !== idx));
};
// ---- save ----
// ---- save ---- // Logan.Li
const save = async () => {
// validate
for (const c of [...initContainers, ...containers]) {
@@ -494,538 +207,4 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
</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 Record<string, 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>
);
}
}
@@ -0,0 +1,161 @@
// DnsHostSection.tsx — DNS 配置 + 主机别名 // Logan.Li
import { Globe, MapPin, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export interface DnsOptionEntry {
name: string;
value: string;
}
export interface HostAliasEntry {
ip: string;
hostnames: string;
}
export interface DnsHostSectionProps {
dnsNameservers: string;
onDnsNameserversChange: (value: string) => void;
dnsSearches: string;
onDnsSearchesChange: (value: string) => void;
dnsOptions: DnsOptionEntry[];
onDnsOptionsChange: (options: DnsOptionEntry[]) => void;
hostAliases: HostAliasEntry[];
onHostAliasesChange: (aliases: HostAliasEntry[]) => void;
}
export function DnsHostSection({
dnsNameservers,
onDnsNameserversChange,
dnsSearches,
onDnsSearchesChange,
dnsOptions,
onDnsOptionsChange,
hostAliases,
onHostAliasesChange,
}: DnsHostSectionProps) {
const addDnsOption = () => onDnsOptionsChange([...dnsOptions, { name: '', value: '' }]);
const updateDnsOption = (index: number, patch: Partial<DnsOptionEntry>) =>
onDnsOptionsChange(dnsOptions.map((o, i) => (i === index ? { ...o, ...patch } : o)));
const removeDnsOption = (index: number) =>
onDnsOptionsChange(dnsOptions.filter((_, i) => i !== index));
const addHostAlias = () => onHostAliasesChange([...hostAliases, { ip: '', hostnames: '' }]);
const updateHostAlias = (index: number, patch: Partial<HostAliasEntry>) =>
onHostAliasesChange(hostAliases.map((a, i) => (i === index ? { ...a, ...patch } : a)));
const removeHostAlias = (index: number) =>
onHostAliasesChange(hostAliases.filter((_, i) => i !== index));
return (
<>
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Globe className="h-4 w-4 text-indigo-400" />DNS 配置 (dnsConfig)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3">
<p className="text-[11px] text-slate-500">
提示:dnsConfig 通常与 <code className="text-slate-400">dnsPolicy: None</code> 搭配使用。每行一个值。
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-slate-500 text-xs">nameservers(每行一个 IP)</Label>
<textarea
value={dnsNameservers}
onChange={e => onDnsNameserversChange(e.target.value)}
placeholder={'8.8.8.8\n114.114.114.114'}
rows={3}
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 font-mono p-2 resize-y"
/>
</div>
<div>
<Label className="text-slate-500 text-xs">searches(搜索域,每行一个)</Label>
<textarea
value={dnsSearches}
onChange={e => onDnsSearchesChange(e.target.value)}
placeholder={'svc.cluster.local\ncluster.local'}
rows={3}
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 font-mono p-2 resize-y"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<Label className="text-slate-400 text-xs">options(DNS 选项)</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={addDnsOption}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{dnsOptions.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置</p>
) : (
dnsOptions.map((o, i) => (
<div key={i} className="flex items-center gap-2 mb-1.5">
<Input
value={o.name}
onChange={e => updateDnsOption(i, { name: e.target.value })}
placeholder="ndots"
className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono"
/>
<span className="text-slate-600">:</span>
<Input
value={o.value}
onChange={e => updateDnsOption(i, { value: e.target.value })}
placeholder="5(可留空)"
className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono"
/>
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400" onClick={() => removeDnsOption(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<MapPin className="h-4 w-4 text-amber-400" />主机别名 (hostAliases)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
<div className="flex items-center justify-between mb-1">
<Label className="text-slate-400 text-xs">IP → 主机名映射(注入 Pod 的 /etc/hosts)</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={addHostAlias}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{hostAliases.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置</p>
) : (
hostAliases.map((a, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={a.ip}
onChange={e => updateHostAlias(i, { ip: e.target.value })}
placeholder="10.10.10.72"
className="w-40 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono"
/>
<span className="text-slate-600">→</span>
<Input
value={a.hostnames}
onChange={e => updateHostAlias(i, { hostnames: e.target.value })}
placeholder="gitlab.local, registry.local"
className="flex-1 h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono"
/>
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400" onClick={() => removeHostAlias(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))
)}
</CardContent>
</Card>
</>
);
}
@@ -0,0 +1,212 @@
// SchedulingSection.tsx — 调度:节点名称 / 标签选择器 / 节点亲和性 // Logan.Li
import { Loader2, Plus, Server, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
export type ScheduleMode = 'nodeName' | 'nodeSelector' | 'nodeAffinity';
export interface LabelSelectorEntry {
key: string;
value: string;
}
export interface ClusterNodeOption {
name: string;
ip: string;
labels: Record<string, string>;
}
export interface SchedulingSectionProps {
scheduleMode: ScheduleMode;
onScheduleModeChange: (mode: ScheduleMode) => void;
nodeName: string;
onNodeNameChange: (name: string) => void;
availableNodes: ClusterNodeOption[];
nodesLoading: boolean;
labelSelectorKv: LabelSelectorEntry[];
onAddLabelSelector: () => void;
onRemoveLabelSelector: (index: number) => void;
onUpdateLabelSelector: (index: number, patch: Partial<LabelSelectorEntry>) => void;
nodeLabels: Record<string, string[]>;
}
const SCHEDULE_MODES: Array<{ key: ScheduleMode; label: string }> = [
{ key: 'nodeName', label: '节点名称' },
{ key: 'nodeSelector', label: '标签选择器' },
{ key: 'nodeAffinity', label: '节点亲和性' },
];
export function SchedulingSection({
scheduleMode,
onScheduleModeChange,
nodeName,
onNodeNameChange,
availableNodes,
nodesLoading,
labelSelectorKv,
onAddLabelSelector,
onRemoveLabelSelector,
onUpdateLabelSelector,
nodeLabels,
}: SchedulingSectionProps) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Server className="h-4 w-4 text-emerald-400" />调度 (Scheduling)
</CardTitle>
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-0.5 border border-slate-700">
{SCHEDULE_MODES.map(m => (
<button
key={m.key}
onClick={() => onScheduleModeChange(m.key)}
className={`px-2.5 py-1 rounded text-[10px] font-medium transition-all ${
scheduleMode === m.key
? 'bg-emerald-500/20 text-emerald-300 shadow-sm'
: 'text-slate-400 hover:text-slate-300'
}`}
>
{m.label}
</button>
))}
</div>
</div>
<p className="text-xs text-slate-500 mt-1">
{scheduleMode === 'nodeName' && 'Pod 将直接调度到指定节点'}
{scheduleMode === 'nodeSelector' && 'Pod 将调度到匹配标签的节点'}
{scheduleMode === 'nodeAffinity' && '通过亲和性规则控制 Pod 调度到指定节点'}
</p>
</CardHeader>
<CardContent className="pt-0">
{scheduleMode === 'nodeName' && (
<div className="space-y-2">
<Label className="text-slate-400 text-xs">请选择节点</Label>
{nodesLoading ? (
<div className="flex items-center gap-1 text-slate-500 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />加载节点列表...
</div>
) : (
<select
value={nodeName}
onChange={e => onNodeNameChange(e.target.value)}
className="h-8 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2"
>
<option value="">(不指定)</option>
{availableNodes.map(n => (
<option key={n.name} value={n.name}>
{n.name} {n.ip ? `(${n.ip})` : ''}
</option>
))}
</select>
)}
{nodeName && (
<div className="text-[10px] text-slate-500">
已选择: <code className="bg-slate-800 px-1 py-0.5 rounded text-emerald-400">{nodeName}</code>
</div>
)}
</div>
)}
{scheduleMode === 'nodeSelector' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-slate-400 text-xs">请选择标签</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={onAddLabelSelector}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{labelSelectorKv.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置标签选择器</p>
) : (
labelSelectorKv.map((n, i) => (
<div key={i} className="flex items-center gap-2">
<select
value={n.key}
onChange={e => onUpdateLabelSelector(i, { key: e.target.value, value: '' })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
>
<option value="">选择标签</option>
{Object.entries(nodeLabels).map(([k, vals]) => (
<option key={k} value={k}>{k} ({vals.length})</option>
))}
</select>
<select
value={n.value}
onChange={e => onUpdateLabelSelector(i, { value: e.target.value })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
disabled={!n.key}
>
<option value="">选择值</option>
{(nodeLabels[n.key] || []).map(v => (
<option key={v} value={v}>{v}</option>
))}
</select>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-400"
onClick={() => onRemoveLabelSelector(i)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
)}
{scheduleMode === 'nodeAffinity' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-slate-400 text-xs">
请选择标签 <span className="text-slate-600">(生成 requiredDuringScheduling 规则)</span>
</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={onAddLabelSelector}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{labelSelectorKv.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置亲和性规则</p>
) : (
labelSelectorKv.map((n, i) => (
<div key={i} className="flex items-center gap-2">
<select
value={n.key}
onChange={e => onUpdateLabelSelector(i, { key: e.target.value, value: '' })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
>
<option value="">选择标签</option>
{Object.entries(nodeLabels).map(([k, vals]) => (
<option key={k} value={k}>{k} ({vals.length})</option>
))}
</select>
<select
value={n.value}
onChange={e => onUpdateLabelSelector(i, { value: e.target.value })}
className="flex-1 h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5"
disabled={!n.key}
>
<option value="">选择值</option>
{(nodeLabels[n.key] || []).map(v => (
<option key={v} value={v}>{v}</option>
))}
</select>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-400"
onClick={() => onRemoveLabelSelector(i)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,453 @@
// ContainerEditor.tsx — 单容器完整编辑(基本/命令/环境变量/端口/资源/探针/生命周期/安全/卷挂载)
// 作者: Logan <admin@attacker.club>
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 {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import {
Plus,
Trash2,
Cpu,
Heart,
FolderOpen,
Terminal,
Shield,
Zap,
Network,
} from 'lucide-react';
import { arrToText, textToArr } from './container-utils';
import type {
ContainerEditorProps, ProbeEditorProps, LifecycleHandlerEditorProps,
BoolToggleProps, ProbeDef, LifecycleHandler,
} from './types';
export function ContainerEditor({ container, onUpdate }: ContainerEditorProps) {
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 Record<string, 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>
</>
);
}
function ProbeEditor({ label, probe, onChange }: ProbeEditorProps) {
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>
);
}
function LifecycleHandlerEditor({ label, handler, onChange }: LifecycleHandlerEditorProps) {
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>
);
}
function BoolToggle({ label, value, onChange }: BoolToggleProps) {
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>
);
}
@@ -0,0 +1,90 @@
// ContainerGroupSection.tsx — 容器分组(初始化/工作容器)折叠列表
// 作者: Logan <admin@attacker.club>
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Plus,
Trash2,
Container,
ChevronDown,
ChevronRight,
RotateCw,
} from 'lucide-react';
import { ContainerEditor } from './ContainerEditor';
import type { ContainerGroupSectionProps } from './types';
export function ContainerGroupSection({
title, description, icon, containers, accentColor,
onAdd, onRemove, onUpdate, onDuplicate,
}: ContainerGroupSectionProps) {
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>
);
}
@@ -0,0 +1,213 @@
// container-utils.ts — K8s container spec ↔ form 转换工具
// 作者: Logan <admin@attacker.club>
import type {
ContainerForm, ProbeDef, LifecycleHandler,
} from './types';
// K8s container spec → form conversion // Logan.Li
export 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 // Logan.Li
export 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: [],
};
}
export function probeToK8s(p: ProbeDef | null | undefined): any {
if (!p || p.type === 'none' || !p.type) return undefined;
const out: Record<string, 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;
}
export 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: Record<string, 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: Record<string, any> = { tcpSocket: {} };
if (h.tcpPort !== '' && h.tcpPort != null) o.tcpSocket.port = Number(h.tcpPort);
return o;
}
return undefined;
}
export function containerToK8s(c: ContainerForm): any {
const out: Record<string, 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: Record<string, 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: Record<string, 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: Record<string, 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: Record<string, 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: Record<string, 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) // Logan.Li
export function arrToText(arr: string[]): string { return arr.join('\n'); }
export function textToArr(text: string): string[] { return text.split('\n').map(s => s.trim()).filter(Boolean); }
@@ -0,0 +1,105 @@
// deployment-edit/types.ts — 容器编辑器类型定义
// 作者: Logan <admin@attacker.club>
export interface EnvVar { name: string; value?: string; }
export interface PortDef { name?: string; containerPort: number | string; protocol?: string; }
export interface VolumeMount { name: string; mountPath: string; subPath?: string; readOnly?: boolean; }
export interface ResourceMap { cpu?: string; memory?: string; }
export 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;
}
export interface LifecycleHandler {
type?: 'exec' | 'httpGet' | 'tcpSocket' | 'none';
execCommand?: string;
httpPath?: string; httpPort?: number | string;
tcpPort?: number | string;
}
export interface LifecycleDef { postStart?: LifecycleHandler; preStop?: LifecycleHandler; }
export interface SecurityContext {
runAsUser?: number | string;
runAsGroup?: number | string;
runAsNonRoot?: boolean;
privileged?: boolean;
readOnlyRootFilesystem?: boolean;
allowPrivilegeEscalation?: boolean;
capabilitiesAdd?: string[];
capabilitiesDrop?: string[];
}
export 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[];
}
export interface DeploymentEditContainersProps {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onCancel: () => void;
}
export interface ContainerGroupSectionProps {
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;
}
export interface ContainerEditorProps {
container: ContainerForm;
onUpdate: (patch: Partial<ContainerForm>) => void;
}
export interface ProbeEditorProps {
label: string;
probe: ProbeDef | null | undefined;
onChange: (p: ProbeDef | null) => void;
}
export interface LifecycleHandlerEditorProps {
label: string;
handler: LifecycleHandler | undefined;
onChange: (h: LifecycleHandler) => void;
}
export interface BoolToggleProps {
label: string;
value: boolean;
onChange: (v: boolean) => void;
}
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -12,26 +12,10 @@ import {
TableRow,
} from '@/components/ui/table';
import { Search, RefreshCw, Trash2, Play } from 'lucide-react';
import { terminalSessionApi } from '@/services/api';
import { useTerminalSessions, useTerminalSessionMutations, type TerminalSession } from '@/hooks/api/useTerminalSessions';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { ReplayDialog } from './ReplayDialog';
interface TerminalSession {
id: number;
user_id?: number;
username: string;
server_id: number;
server_name: string;
server_ip: string;
server_port: number;
client_ip: string;
status: string;
duration: number;
record_size: number;
start_time: string;
end_time?: string | null;
}
function formatDuration(sec: number): string {
if (!sec || sec < 0) return '--';
if (sec < 60) return `${sec}秒`;
@@ -55,30 +39,14 @@ function formatSize(bytes: number): string {
*/
export function TerminalSessionsTab() {
const confirm = useConfirm();
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [loading, setLoading] = useState(true);
const { data: sessions = [], isLoading: loading, refetch } = useTerminalSessions({ page: 1, page_size: 100 });
const { deleteMutation, batchDeleteMutation } = useTerminalSessionMutations();
const [keyword, setKeyword] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [selected, setSelected] = useState<Set<number>>(new Set());
const [replayId, setReplayId] = useState<number | null>(null);
const [replayOpen, setReplayOpen] = useState(false);
const fetchSessions = async () => {
setLoading(true);
try {
const resp = await terminalSessionApi.list({ page: 1, page_size: 100 }) as any;
setSessions(resp?.list ?? []);
} catch {
setSessions([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSessions();
}, []);
const filtered = sessions.filter((s) => {
const kw = keyword.toLowerCase();
const kwMatch =
@@ -142,13 +110,12 @@ export function TerminalSessionsTab() {
});
if (!ok) return;
try {
await terminalSessionApi.delete(id);
await deleteMutation.mutateAsync(id);
setSelected((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
await fetchSessions();
} catch {
/* noop */
}
@@ -164,9 +131,8 @@ export function TerminalSessionsTab() {
});
if (!ok) return;
try {
await terminalSessionApi.batchDelete(Array.from(selected));
await batchDeleteMutation.mutateAsync(Array.from(selected));
setSelected(new Set());
await fetchSessions();
} catch {
/* noop */
}
@@ -207,7 +173,7 @@ export function TerminalSessionsTab() {
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={fetchSessions}
onClick={() => refetch()}
>
<RefreshCw className="h-4 w-4 mr-2" />
刷新