fix: YAML查看器按钮文案 '清理并编辑' → '编辑',统一简洁命名

This commit is contained in:
attacker committed 2026-06-23 01:23:27 +00:00
1 parent a8b4af4409
commit 4bd9fa629b
14 files changed
+2146 -400

No files matched your search

@@ -1,374 +0,0 @@
// DeploymentEdit.tsx — 表单式编辑器(环境变量/资源/探针/挂载)
// 作者: Logan <admin@attacker.club>
// 说明: 前端 UI 先行;保存通过 PUT application/yaml(清理后的 manifest)落地。
import { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Plus, Trash2, Save, Loader2, Container, Cpu, MemoryStick, Heart, FolderOpen } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/services/api';
import { cleanYAML } from './k8s-yaml-utils';
interface ContainerSpec {
name: string;
image: string;
resources?: {
requests?: { cpu?: string; memory?: string };
limits?: { cpu?: string; memory?: string };
};
env?: Array<{ name: string; value?: string; valueFrom?: any }>;
livenessProbe?: any;
readinessProbe?: any;
startupProbe?: any;
volumeMounts?: Array<{ name: string; mountPath: string; readOnly?: boolean }>;
ports?: Array<{ containerPort: number; name?: string; protocol?: string }>;
}
interface DeploymentEditProps {
clusterCode: string;
namespace: string;
deploymentName: string;
/** 从后端拿到的 manifest 对象(spec.template.spec.containers 为编辑目标) */
manifest: any | null;
onSaved: () => void;
onCancel: () => void;
}
/** 把扁平的 deployment 字段或后端 manifest 归一化为可编辑的容器 spec 数组 */
function toContainerSpecs(deployment: any): ContainerSpec[] {
// 优先用 manifest 里的结构化容器
const fromManifest = deployment?.spec?.template?.spec?.containers;
if (Array.isArray(fromManifest) && fromManifest.length > 0) {
return fromManifest.map((c: any) => ({
name: c.name,
image: c.image,
resources: c.resources || {},
env: Array.isArray(c.env) ? c.env : [],
livenessProbe: c.livenessProbe || null,
readinessProbe: c.readinessProbe || null,
startupProbe: c.startupProbe || null,
volumeMounts: Array.isArray(c.volumeMounts) ? c.volumeMounts : [],
ports: Array.isArray(c.ports) ? c.ports : [],
}));
}
// 降级:从扁平字段构造单个容器
return [{
name: deployment?.container_name || deployment?.name || 'main',
image: deployment?.image || '',
resources: {
requests: { cpu: deployment?.cpu_request, memory: deployment?.memory_request },
limits: { cpu: deployment?.cpu_limit, memory: deployment?.memory_limit },
},
env: [],
volumeMounts: [],
ports: [],
}];
}
export function DeploymentEdit({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: DeploymentEditProps) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [rawManifest, setRawManifest] = useState<any>(manifest || {});
const [containers, setContainers] = useState<ContainerSpec[]>(() => toContainerSpecs(manifest));
// 副本数 & 镜像策略顶层字段
const replicas = rawManifest?.spec?.replicas ?? manifest?.replicas ?? 1;
const setReplicas = (n: number) =>
setRawManifest((m: any) => ({ ...m, spec: { ...(m.spec || {}), replicas: n } }));
const updateContainer = (idx: number, patch: Partial<ContainerSpec>) => {
setContainers(prev => prev.map((c, i) => (i === idx ? { ...c, ...patch } : c)));
};
// ============ Env ============
const addEnv = (ci: number) => {
const env = [...(containers[ci].env || []), { name: '', value: '' }];
updateContainer(ci, { env });
};
const updateEnv = (ci: number, ei: number, patch: Partial<{ name: string; value: string }>) => {
const env = (containers[ci].env || []).map((e, i) => (i === ei ? { ...e, ...patch } : e));
updateContainer(ci, { env });
};
const removeEnv = (ci: number, ei: number) => {
updateContainer(ci, { env: (containers[ci].env || []).filter((_, i) => i !== ei) });
};
// ============ Resources ============
const updateResource = (ci: number, kind: 'requests' | 'limits', key: 'cpu' | 'memory', val: string) => {
const c = containers[ci];
const resources = { ...(c.resources || {}) };
resources[kind] = { ...(resources[kind] || {}), [key]: val };
updateContainer(ci, { resources });
};
// ============ Probe(简化:仅编辑 httpGet.path/port + initialDelaySeconds + periodSeconds) ============
const updateProbe = (ci: number, probeType: 'livenessProbe' | 'readinessProbe' | 'startupProbe', patch: any) => {
const c = containers[ci];
const existing = c[probeType] || {};
updateContainer(ci, { [probeType]: { ...existing, ...patch } } as any);
};
const toggleProbe = (ci: number, probeType: 'livenessProbe' | 'readinessProbe' | 'startupProbe') => {
const c = containers[ci];
if (c[probeType]) {
updateContainer(ci, { [probeType]: null } as any);
} else {
updateContainer(ci, { [probeType]: { httpGet: { path: '/', port: 8080 }, initialDelaySeconds: 10, periodSeconds: 10 } } as any);
}
};
// ============ VolumeMount ============
const addMount = (ci: number) => {
const mounts = [...(containers[ci].volumeMounts || []), { name: '', mountPath: '' }];
updateContainer(ci, { volumeMounts: mounts });
};
const updateMount = (ci: number, mi: number, patch: Partial<{ name: string; mountPath: string }>) => {
const mounts = (containers[ci].volumeMounts || []).map((m, i) => (i === mi ? { ...m, ...patch } : m));
updateContainer(ci, { volumeMounts: mounts });
};
const removeMount = (ci: number, mi: number) => {
updateContainer(ci, { volumeMounts: (containers[ci].volumeMounts || []).filter((_, i) => i !== mi) });
};
// ============ 序列化为 YAML 并保存 ============
const buildManifest = useMemo(() => {
const base = JSON.parse(JSON.stringify(rawManifest && Object.keys(rawManifest).length ? rawManifest : manifest || {}));
base.spec = base.spec || {};
base.spec.replicas = replicas;
base.spec.template = base.spec.template || {};
base.spec.template.spec = base.spec.template.spec || {};
base.spec.template.spec.containers = containers.map(c => {
const out: any = { name: c.name, image: c.image };
if (c.resources && Object.keys(c.resources).length) out.resources = c.resources;
if (c.env && c.env.length) out.env = c.env.filter(e => e.name);
if (c.volumeMounts && c.volumeMounts.length) out.volumeMounts = c.volumeMounts;
if (c.ports && c.ports.length) out.ports = c.ports;
if (c.livenessProbe) out.livenessProbe = c.livenessProbe;
if (c.readinessProbe) out.readinessProbe = c.readinessProbe;
if (c.startupProbe) out.startupProbe = c.startupProbe;
return out;
});
return base;
}, [rawManifest, manifest, replicas, containers]);
// 简易 YAML 序列化(不引入 js-yaml,仅处理扁平/二层结构用于编辑落地)
// 复杂嵌套交给后端 application/yaml 端点容忍 JSON-ish YAML;大多数 K8s API server 接受 JSON 文档作为 YAML 提交。
const save = async () => {
setSaving(true);
try {
const json = JSON.stringify(buildManifest, null, 2);
// 后端 yaml 端点一般同时接受 JSON 文档(YAML 是 JSON 超集)
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json);
toast({ title: '编辑已保存', description: `${deploymentName} 更新已提交` });
onSaved();
} catch (e: any) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
};
if (!manifest) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8 text-center text-slate-500">
未获取到 manifest 结构化数据,请使用「编辑 YAML」模式直接修改原文。
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{/* 顶层:副本数 */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3"><CardTitle className="text-sm text-slate-200">副本与镜像策略</CardTitle></CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-4">
<div>
<Label className="text-slate-400 text-xs">副本数 (replicas)</Label>
<Input type="number" min={0} value={replicas}
onChange={e => setReplicas(Number(e.target.value))}
className="w-32 mt-1 bg-slate-800 border-slate-700 text-slate-200" />
</div>
</div>
</CardContent>
</Card>
{/* 每个容器一组编辑卡片 */}
{containers.map((c, ci) => (
<Card key={ci} className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Container className="h-4 w-4 text-blue-400" />
容器: <span className="font-mono text-blue-300">{c.name}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 镜像 */}
<div>
<Label className="text-slate-400 text-xs">镜像 (image)</Label>
<Input value={c.image} onChange={e => updateContainer(ci, { image: e.target.value })}
placeholder="harbor.example.com/app:v1.0"
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 font-mono text-sm" />
</div>
{/* 资源 requests/limits */}
<div>
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-amber-400" />
<span className="text-xs text-slate-300 font-semibold">资源 requests / limits</span>
</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 => updateResource(ci, '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 => updateResource(ci, '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 => updateResource(ci, '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 => updateResource(ci, '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="flex items-center gap-2 mb-2">
<Heart className="h-4 w-4 text-rose-400" />
<span className="text-xs text-slate-300 font-semibold">健康探针</span>
</div>
<div className="space-y-2">
{(['livenessProbe', 'readinessProbe', 'startupProbe'] as const).map(pt => {
const labels = { livenessProbe: '存活探针', readinessProbe: '就绪探针', startupProbe: '启动探针' };
const probe = (c as any)[pt];
const enabled = !!probe;
return (
<div key={pt} className="border border-slate-800 rounded-md p-2 bg-slate-950/30">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-300">{labels[pt]}</span>
<Button size="sm" variant={enabled ? 'destructive' : 'outline'}
className="h-6 text-[11px]"
onClick={() => toggleProbe(ci, pt)}>
{enabled ? '移除' : '启用'}
</Button>
</div>
{enabled && probe?.httpGet && (
<div className="grid grid-cols-3 gap-2 mt-2">
<div>
<Label className="text-[10px] text-slate-500">路径</Label>
<Input value={probe.httpGet.path || ''} placeholder="/healthz"
onChange={e => updateProbe(ci, pt, { httpGet: { ...probe.httpGet, path: e.target.value } })}
className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 font-mono" />
</div>
<div>
<Label className="text-[10px] text-slate-500">端口</Label>
<Input value={probe.httpGet.port ?? ''} placeholder="8080"
onChange={e => updateProbe(ci, pt, { httpGet: { ...probe.httpGet, port: Number(e.target.value) } })}
className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 font-mono" />
</div>
<div>
<Label className="text-[10px] text-slate-500">延迟(s)</Label>
<Input type="number" value={probe.initialDelaySeconds ?? ''} placeholder="10"
onChange={e => updateProbe(ci, pt, { initialDelaySeconds: Number(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-200 text-xs h-7 font-mono" />
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* 环境变量 */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-300 font-semibold">环境变量</span>
<Badge variant="outline" className="border-slate-700 text-slate-500 text-[10px]">{c.env?.length || 0}</Badge>
</div>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => addEnv(ci)}>
<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 => updateEnv(ci, ei, { name: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input value={env.value || ''} placeholder="value"
onChange={e => updateEnv(ci, ei, { value: e.target.value })}
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 hover:text-red-300"
onClick={() => removeEnv(ci, ei)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
))}
{(!c.env || 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="flex items-center gap-2">
<FolderOpen className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-xs text-slate-300 font-semibold">Volume 挂载</span>
</div>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => addMount(ci)}>
<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_1.4fr_auto] gap-2">
<Input value={m.name} placeholder="volume 名称"
onChange={e => updateMount(ci, mi, { name: e.target.value })}
className="bg-slate-800 border-slate-700 text-slate-200 font-mono text-xs h-8" />
<Input value={m.mountPath} placeholder="/path/to/mount"
onChange={e => updateMount(ci, mi, { mountPath: e.target.value })}
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 hover:text-red-300"
onClick={() => removeMount(ci, mi)}><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
))}
{(!c.volumeMounts || c.volumeMounts.length === 0) && <p className="text-[11px] text-slate-600">无卷挂载</p>}
</div>
</div>
</CardContent>
</Card>
))}
{/* 保存栏 */}
<div className="sticky bottom-0 bg-slate-950/80 backdrop-blur border-t border-slate-800 p-3 flex items-center justify-between">
<p className="text-[11px] text-slate-500">
保存将通过 <code className="text-slate-400">PUT application/yaml</code> 提交(自动剥离 status/managedFields 等只读字段)
</p>
<div className="flex gap-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onCancel}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={saving} onClick={save}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
保存
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,632 @@
// DeploymentEditAdvanced.tsx — 编辑器「高级设置」Tab (M8)
// 编辑内容:更新策略 / 历史版本 / 调度(节点选择/容忍) / DNS / 安全上下文 / 终止宽限期 / 调度器 / SA
// 作者: Logan <admin@attacker.club>
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 { Badge } from '@/components/ui/badge';
import {
Save, Loader2, Settings, RefreshCw, Server, Shield, Clock, Network, Plus, Trash2, Globe, MapPin,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/services/api';
interface Props {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onCancel: () => void;
}
interface Toleration {
key: string;
operator: string; // Equal | Exists
value: string;
effect: string; // NoSchedule | PreferNoSchedule | NoExecute
tolerationSeconds: string;
}
export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: Props) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest);
const [rawManifest, setRawManifest] = useState<any>(manifest || {});
// 更新策略
const [strategyType, setStrategyType] = useState<'RollingUpdate' | 'Recreate'>('RollingUpdate');
const [maxSurge, setMaxSurge] = useState('25%');
const [maxUnavailable, setMaxUnavailable] = useState('25%');
// 历史/进度
const [revisionHistoryLimit, setRevisionHistoryLimit] = useState('10');
const [progressDeadlineSeconds, setProgressDeadlineSeconds] = useState('600');
// Pod 级别
const [nodeSelectorKv, setNodeSelectorKv] = useState<Array<{ key: string; value: string }>>([]);
const [tolerations, setTolerations] = useState<Toleration[]>([]);
const [dnsPolicy, setDnsPolicy] = useState('ClusterFirst');
const [restartPolicy, setRestartPolicy] = useState('Always');
const [schedulerName, setSchedulerName] = useState('default-scheduler');
const [terminationGracePeriodSeconds, setTerminationGracePeriodSeconds] = useState('30');
const [serviceAccountName, setServiceAccountName] = useState('');
// Pod 安全上下文
const [podFsGroup, setPodFsGroup] = useState('');
const [podRunAsUser, setPodRunAsUser] = useState('');
const [podRunAsGroup, setPodRunAsGroup] = useState('');
const [podRunAsNonRoot, setPodRunAsNonRoot] = useState(false);
// 主机设置(M8 补全)
const [hostNetwork, setHostNetwork] = useState(false);
const [hostIPC, setHostIPC] = useState(false);
const [hostPID, setHostPID] = useState(false);
const [shareProcessNamespace, setShareProcessNamespace] = useState(false);
// hostAliases(M8 补全)
const [hostAliases, setHostAliases] = useState<Array<{ ip: string; hostnames: string }>>([]);
// dnsConfig(M8 补全)
const [dnsNameservers, setDnsNameservers] = useState('');
const [dnsSearches, setDnsSearches] = useState('');
const [dnsOptions, setDnsOptions] = useState<Array<{ name: string; value: string }>>([]);
useEffect(() => {
const m = manifest;
if (!m) { setLoading(false); return; }
setRawManifest(m);
const spec = m?.spec || {};
const podSpec = spec?.template?.spec || {};
// 更新策略
const strat = spec.strategy || {};
setStrategyType(strat.type || 'RollingUpdate');
const ru = strat.rollingUpdate || {};
setMaxSurge(String(ru.maxSurge ?? '25%'));
setMaxUnavailable(String(ru.maxUnavailable ?? '25%'));
// 历史/进度
setRevisionHistoryLimit(spec.revisionHistoryLimit != null ? String(spec.revisionHistoryLimit) : '10');
setProgressDeadlineSeconds(spec.progressDeadlineSeconds != null ? String(spec.progressDeadlineSeconds) : '600');
// nodeSelector
const ns = podSpec.nodeSelector || {};
setNodeSelectorKv(Object.entries(ns).map(([k, v]) => ({ key: k, value: String(v) })));
// tolerations
const tols: any[] = Array.isArray(podSpec.tolerations) ? podSpec.tolerations : [];
setTolerations(tols.map(t => ({
key: t.key || '',
operator: t.operator || 'Equal',
value: t.value || '',
effect: t.effect || '',
tolerationSeconds: t.tolerationSeconds != null ? String(t.tolerationSeconds) : '',
})));
setDnsPolicy(podSpec.dnsPolicy || 'ClusterFirst');
setRestartPolicy(podSpec.restartPolicy || 'Always');
setSchedulerName(podSpec.schedulerName || 'default-scheduler');
setTerminationGracePeriodSeconds(podSpec.terminationGracePeriodSeconds != null ? String(podSpec.terminationGracePeriodSeconds) : '30');
setServiceAccountName(podSpec.serviceAccountName || '');
// Pod securityContext
const sc = podSpec.securityContext || {};
setPodFsGroup(sc.fsGroup != null ? String(sc.fsGroup) : '');
setPodRunAsUser(sc.runAsUser != null ? String(sc.runAsUser) : '');
setPodRunAsGroup(sc.runAsGroup != null ? String(sc.runAsGroup) : '');
setPodRunAsNonRoot(!!sc.runAsNonRoot);
// 主机设置(M8 补全)
setHostNetwork(!!podSpec.hostNetwork);
setHostIPC(!!podSpec.hostIPC);
setHostPID(!!podSpec.hostPID);
setShareProcessNamespace(!!podSpec.shareProcessNamespace);
// hostAliases(M8 补全)
const ha: any[] = Array.isArray(podSpec.hostAliases) ? podSpec.hostAliases : [];
setHostAliases(ha.map(a => ({
ip: a.ip || '',
hostnames: Array.isArray(a.hostnames) ? a.hostnames.join(', ') : '',
})));
// dnsConfig(M8 补全)
const dc = podSpec.dnsConfig || {};
setDnsNameservers(Array.isArray(dc.nameservers) ? dc.nameservers.join('\n') : '');
setDnsSearches(Array.isArray(dc.searches) ? dc.searches.join('\n') : '');
const opts: any[] = Array.isArray(dc.options) ? dc.options : [];
setDnsOptions(opts.map(o => ({ name: o.name || '', value: o.value || '' })));
setLoading(false);
}, [manifest]);
// nodeSelector 操作
const addNs = () => setNodeSelectorKv(prev => [...prev, { key: '', value: '' }]);
const removeNs = (i: number) => setNodeSelectorKv(prev => prev.filter((_, idx) => idx !== i));
const updateNs = (i: number, patch: Partial<{ key: string; value: string }>) =>
setNodeSelectorKv(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 {
const base = JSON.parse(JSON.stringify(rawManifest));
base.spec = base.spec || {};
base.spec.template = base.spec.template || {};
base.spec.template.spec = base.spec.template.spec || {};
const podSpec = base.spec.template.spec;
// 更新策略
base.spec.strategy = { type: strategyType };
if (strategyType === 'RollingUpdate') {
base.spec.strategy.rollingUpdate = { maxSurge, maxUnavailable };
}
// 历史/进度
if (revisionHistoryLimit) base.spec.revisionHistoryLimit = Number(revisionHistoryLimit);
if (progressDeadlineSeconds) base.spec.progressDeadlineSeconds = Number(progressDeadlineSeconds);
// nodeSelector
const nsObj: Record<string, string> = {};
for (const { key, value } of nodeSelectorKv) {
const k = key.trim();
if (k) nsObj[k] = value;
}
if (Object.keys(nsObj).length > 0) podSpec.nodeSelector = nsObj;
else delete podSpec.nodeSelector;
// tolerations
const tolsK8s = tolerations
.filter(t => t.key || t.operator === 'Exists')
.map(t => {
const out: any = {};
if (t.key) out.key = t.key;
if (t.operator) out.operator = t.operator;
if (t.value && t.operator !== 'Exists') out.value = t.value;
if (t.effect) out.effect = t.effect;
if (t.tolerationSeconds && t.effect === 'NoExecute') out.tolerationSeconds = Number(t.tolerationSeconds);
return out;
});
if (tolsK8s.length > 0) podSpec.tolerations = tolsK8s;
else delete podSpec.tolerations;
// 其他 Pod 级别
if (dnsPolicy) podSpec.dnsPolicy = dnsPolicy;
if (restartPolicy) podSpec.restartPolicy = restartPolicy;
if (schedulerName) podSpec.schedulerName = schedulerName;
if (terminationGracePeriodSeconds) podSpec.terminationGracePeriodSeconds = Number(terminationGracePeriodSeconds);
if (serviceAccountName) podSpec.serviceAccountName = serviceAccountName;
// Pod 安全上下文
const sc: any = {};
if (podFsGroup) sc.fsGroup = Number(podFsGroup);
if (podRunAsUser) sc.runAsUser = Number(podRunAsUser);
if (podRunAsGroup) sc.runAsGroup = Number(podRunAsGroup);
if (podRunAsNonRoot) sc.runAsNonRoot = true;
if (Object.keys(sc).length > 0) podSpec.securityContext = sc;
else delete podSpec.securityContext;
// 主机设置(M8 补全)— 只在为 true 时写入,false 删除字段
if (hostNetwork) podSpec.hostNetwork = true; else delete podSpec.hostNetwork;
if (hostIPC) podSpec.hostIPC = true; else delete podSpec.hostIPC;
if (hostPID) podSpec.hostPID = true; else delete podSpec.hostPID;
if (shareProcessNamespace) podSpec.shareProcessNamespace = true; else delete podSpec.shareProcessNamespace;
// hostAliases(M8 补全)
const haK8s = hostAliases
.filter(a => a.ip.trim())
.map(a => ({
ip: a.ip.trim(),
hostnames: a.hostnames.split(/[,\s]+/).map((s: string) => s.trim()).filter(Boolean),
}))
.filter(a => a.hostnames.length > 0);
if (haK8s.length > 0) podSpec.hostAliases = haK8s;
else delete podSpec.hostAliases;
// dnsConfig(M8 补全)— 仅 dnsPolicy=None 时 dnsConfig 才有意义,但仍允许编辑
const dcObj: any = {};
const ns = dnsNameservers.split('\n').map(s => s.trim()).filter(Boolean);
if (ns.length > 0) dcObj.nameservers = ns;
const sr = dnsSearches.split('\n').map(s => s.trim()).filter(Boolean);
if (sr.length > 0) dcObj.searches = sr;
const dkOpts = dnsOptions.filter(o => o.name.trim()).map(o => {
const out: any = { name: o.name.trim() };
if (o.value.trim()) out.value = o.value.trim();
return out;
});
if (dkOpts.length > 0) dcObj.options = dkOpts;
if (Object.keys(dcObj).length > 0) podSpec.dnsConfig = dcObj;
else delete podSpec.dnsConfig;
const json = JSON.stringify(base, null, 2);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json);
toast({ title: '高级设置已保存', description: `${deploymentName} 更新已提交` });
onSaved();
} catch (e: any) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
};
if (loading) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-12 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-slate-500" />
<p className="text-slate-500 mt-3 text-sm">加载中...</p>
</CardContent>
</Card>
);
}
if (!manifest) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8 text-center text-slate-500">
未获取到 manifest 数据,请使用 YAML 编辑模式直接修改。
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{/* 更新策略 */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-blue-400" />更新策略 (Strategy)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3">
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 text-xs text-slate-300 cursor-pointer">
<input type="radio" checked={strategyType === 'RollingUpdate'} onChange={() => setStrategyType('RollingUpdate')}
className="accent-blue-500" />
RollingUpdate(滚动更新)
</label>
<label className="flex items-center gap-1.5 text-xs text-slate-300 cursor-pointer">
<input type="radio" checked={strategyType === 'Recreate'} onChange={() => setStrategyType('Recreate')}
className="accent-blue-500" />
Recreate(重建)
</label>
</div>
{strategyType === 'RollingUpdate' && (
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-slate-500 text-xs">maxSurge(整数或百分比,超出期望副本数)</Label>
<Input value={maxSurge} onChange={e => setMaxSurge(e.target.value)}
placeholder="25%" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
<div>
<Label className="text-slate-500 text-xs">maxUnavailable(整数或百分比,不可用上限)</Label>
<Input value={maxUnavailable} onChange={e => setMaxUnavailable(e.target.value)}
placeholder="25%" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</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">
<Clock className="h-4 w-4 text-amber-400" />历史版本 & 进度
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-3">
<div>
<Label className="text-slate-500 text-xs">revisionHistoryLimit(保留 RS 历史)</Label>
<Input type="number" value={revisionHistoryLimit} onChange={e => setRevisionHistoryLimit(e.target.value)}
className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
<div>
<Label className="text-slate-500 text-xs">progressDeadlineSeconds(升级超时秒数)</Label>
<Input type="number" value={progressDeadlineSeconds} onChange={e => setProgressDeadlineSeconds(e.target.value)}
className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
</CardContent>
</Card>
{/* 调度:nodeSelector + tolerations */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Server className="h-4 w-4 text-emerald-400" />调度 (Scheduling)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{/* nodeSelector */}
<div>
<div className="flex items-center justify-between mb-1">
<Label className="text-slate-400 text-xs">nodeSelector(节点标签选择)</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>
{nodeSelectorKv.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置</p>
) : nodeSelectorKv.map((n, i) => (
<div key={i} className="flex items-center gap-2 mb-1.5">
<Input value={n.key} onChange={e => updateNs(i, { key: e.target.value })}
placeholder="disktype" 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={n.value} onChange={e => updateNs(i, { value: e.target.value })}
placeholder="ssd" 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={() => removeNs(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* tolerations */}
<div>
<div className="flex items-center justify-between mb-1">
<Label className="text-slate-400 text-xs">tolerations(污点容忍)</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400" onClick={addTol}>
<Plus className="h-3 w-3 mr-1" />添加
</Button>
</div>
{tolerations.length === 0 ? (
<p className="text-xs text-slate-600 italic">未配置</p>
) : tolerations.map((t, i) => (
<div key={i} className="bg-slate-950/50 border border-slate-800 rounded p-2 mb-1.5 grid grid-cols-5 gap-2 items-end">
<div>
<Label className="text-slate-500 text-[10px]">key</Label>
<Input value={t.key} onChange={e => updateTol(i, { key: e.target.value })}
placeholder="node-role.kubernetes.io/control-plane"
className="h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-[10px]">operator</Label>
<select value={t.operator} onChange={e => updateTol(i, { operator: e.target.value })}
className="h-7 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1">
<option value="Equal">Equal</option>
<option value="Exists">Exists</option>
</select>
</div>
<div>
<Label className="text-slate-500 text-[10px]">value</Label>
<Input value={t.value} onChange={e => updateTol(i, { value: e.target.value })}
disabled={t.operator === 'Exists'}
className="h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono disabled:opacity-40" />
</div>
<div>
<Label className="text-slate-500 text-[10px]">effect</Label>
<select value={t.effect} onChange={e => updateTol(i, { effect: e.target.value })}
className="h-7 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1">
<option value="">(all)</option>
<option value="NoSchedule">NoSchedule</option>
<option value="PreferNoSchedule">PreferNoSchedule</option>
<option value="NoExecute">NoExecute</option>
</select>
</div>
<div className="flex items-center gap-1">
<Input value={t.tolerationSeconds} onChange={e => updateTol(i, { tolerationSeconds: e.target.value })}
disabled={t.effect !== 'NoExecute'} placeholder="秒"
className="h-7 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono disabled:opacity-40" />
<Button size="icon" variant="ghost" className="h-6 w-6 text-red-400" onClick={() => removeTol(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Pod 级别杂项 */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Settings className="h-4 w-4 text-slate-400" />Pod 级别
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-3">
<div>
<Label className="text-slate-500 text-xs">dnsPolicy</Label>
<select value={dnsPolicy} onChange={e => setDnsPolicy(e.target.value)}
className="h-8 mt-1 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2">
<option value="ClusterFirst">ClusterFirst</option>
<option value="ClusterFirstWithHostNet">ClusterFirstWithHostNet</option>
<option value="Default">Default</option>
<option value="None">None</option>
</select>
</div>
<div>
<Label className="text-slate-500 text-xs">restartPolicy</Label>
<select value={restartPolicy} onChange={e => setRestartPolicy(e.target.value)}
className="h-8 mt-1 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2">
<option value="Always">Always</option>
<option value="OnFailure">OnFailure</option>
<option value="Never">Never</option>
</select>
</div>
<div>
<Label className="text-slate-500 text-xs">schedulerName</Label>
<Input value={schedulerName} onChange={e => setSchedulerName(e.target.value)}
className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
<div>
<Label className="text-slate-500 text-xs">terminationGracePeriodSeconds</Label>
<Input type="number" value={terminationGracePeriodSeconds} onChange={e => setTerminationGracePeriodSeconds(e.target.value)}
className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
<div className="col-span-2">
<Label className="text-slate-500 text-xs">serviceAccountName</Label>
<Input value={serviceAccountName} onChange={e => setServiceAccountName(e.target.value)}
placeholder="default" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
</CardContent>
</Card>
{/* Pod 安全上下文 */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Shield className="h-4 w-4 text-rose-400" />Pod 安全上下文 (securityContext)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-3">
<div>
<Label className="text-slate-500 text-xs">fsGroup</Label>
<Input type="number" value={podFsGroup} onChange={e => setPodFsGroup(e.target.value)}
placeholder="1000" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
<div>
<Label className="text-slate-500 text-xs">runAsUser</Label>
<Input type="number" value={podRunAsUser} onChange={e => setPodRunAsUser(e.target.value)}
placeholder="1000" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
<div>
<Label className="text-slate-500 text-xs">runAsGroup</Label>
<Input type="number" value={podRunAsGroup} onChange={e => setPodRunAsGroup(e.target.value)}
placeholder="1000" className="h-8 mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
<input type="checkbox" checked={podRunAsNonRoot} onChange={e => setPodRunAsNonRoot(e.target.checked)}
className="accent-blue-500" />
runAsNonRoot
</label>
</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">
<Network className="h-4 w-4 text-cyan-400" />主机设置 (Host Namespace)
</CardTitle>
</CardHeader>
<CardContent className="pt-0 grid grid-cols-2 gap-3">
<label className="flex items-center gap-2 text-xs text-slate-300 cursor-pointer">
<input type="checkbox" checked={hostNetwork} onChange={e => setHostNetwork(e.target.checked)}
className="accent-blue-500" />
hostNetwork(共享主机网络命名空间)
</label>
<label className="flex items-center gap-2 text-xs text-slate-300 cursor-pointer">
<input type="checkbox" checked={hostIPC} onChange={e => setHostIPC(e.target.checked)}
className="accent-blue-500" />
hostIPC(共享主机 IPC 命名空间)
</label>
<label className="flex items-center gap-2 text-xs text-slate-300 cursor-pointer">
<input type="checkbox" checked={hostPID} onChange={e => setHostPID(e.target.checked)}
className="accent-blue-500" />
hostPID(共享主机 PID 命名空间)
</label>
<label className="flex items-center gap-2 text-xs text-slate-300 cursor-pointer">
<input type="checkbox" checked={shareProcessNamespace} onChange={e => setShareProcessNamespace(e.target.checked)}
className="accent-blue-500" />
shareProcessNamespace(共享进程命名空间)
</label>
</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>
{/* 操作栏 */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onCancel}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={saving} onClick={save}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
保存
</Button>
</div>
</div>
);
}
@@ -0,0 +1,534 @@
// DeploymentEditService.tsx — 编辑器「服务/应用路由」Tab (M9)
// 展示与当前 Deployment 关联的 Service / Ingress,并提供创建入口
// 作者: Logan <admin@attacker.club>
import { useState, useEffect, useCallback } 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 { Badge } from '@/components/ui/badge';
import {
Network, GitBranch, Loader2, RefreshCw, ExternalLink, Plus, Copy,
CheckCircle, AlertTriangle, Server, Trash2,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useUserStore } from '@/store';
import { kubernetesApi } from '@/services/api';
import { YamlViewer } from './YamlViewer';
interface Props {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onCancel: () => void;
}
interface SvcSummary {
name: string;
type: string; // ClusterIP | NodePort | LoadBalancer
clusterIP: string;
externalIP: string;
ports: Array<{ port: number; targetPort: any; protocol: string; nodePort?: number; name?: string }>;
selector: Record<string, string>;
age: string;
raw: any;
}
interface IngSummary {
name: string;
className: string;
hosts: string[];
rules: Array<{ host: string; paths: Array<{ path: string; backend: { service: string; port: any } }> }>;
raw: any;
}
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
export function DeploymentEditService({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: Props) {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [services, setServices] = useState<SvcSummary[]>([]);
const [ingresses, setIngresses] = useState<IngSummary[]>([]);
const [selectorLabels, setSelectorLabels] = useState<Record<string, string>>({});
const [yamlModal, setYamlModal] = useState<{ kind: 'service' | 'ingress'; name: string } | null>(null);
const [showCreate, setShowCreate] = useState<'service' | 'ingress' | null>(null);
// 创建 Service 表单
const [svcForm, setSvcForm] = useState({
name: '', type: 'ClusterIP', portName: 'http', port: '80', targetPort: '80', protocol: 'TCP', nodePort: '',
});
// 创建 Ingress 表单
const [ingForm, setIngForm] = useState({
name: '', className: 'nginx', host: '', path: '/', pathType: 'Prefix', svcPort: '80',
});
const load = useCallback(async () => {
setLoading(true);
try {
// 先从 manifest 取 selector
const sel = manifest?.spec?.selector?.matchLabels || {};
setSelectorLabels(sel);
const [svcRes, ingRes] = await Promise.all([
kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []),
kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []),
]);
const allSvcs = toArray(svcRes);
const allIngs = toArray(ingRes);
const svcSummaries: SvcSummary[] = allSvcs.map((s: any) => {
const spec = s.spec || s.raw?.spec || {};
const ports = Array.isArray(spec.ports) ? spec.ports.map((p: any) => ({
port: p.port, targetPort: p.targetPort, protocol: p.protocol || 'TCP',
nodePort: p.nodePort, name: p.name,
})) : [];
return {
name: s.name || s.metadata?.name || '',
type: spec.type || 'ClusterIP',
clusterIP: spec.clusterIP || '<none>',
externalIP: Array.isArray(spec.externalIPs) ? spec.externalIPs.join(',') : '',
ports,
selector: spec.selector || {},
age: s.age || s.created_at || '',
raw: s,
};
});
const ingSummaries: IngSummary[] = allIngs.map((i: any) => {
const spec = i.spec || i.raw?.spec || {};
const rules: any[] = Array.isArray(spec.rules) ? spec.rules : [];
return {
name: i.name || i.metadata?.name || '',
className: spec.ingressClassName || '<none>',
hosts: rules.map((r: any) => r.host).filter(Boolean),
rules: rules.map((r: any) => ({
host: r.host || '',
paths: (r.http?.paths || []).map((p: any) => ({
path: p.path || '/',
backend: {
service: p.backend?.service?.name || '',
port: p.backend?.service?.port?.number || p.backend?.service?.port?.name || '',
},
})),
})),
raw: i,
};
});
setServices(svcSummaries);
setIngresses(ingSummaries);
} catch (e: any) {
console.error('M9 load error:', e);
} finally { setLoading(false); }
}, [clusterCode, namespace, manifest]);
useEffect(() => { load(); }, [load]);
// 计算 selector 是否匹配
const selectorMatches = (svcSelector: Record<string, string>): boolean => {
if (Object.keys(svcSelector).length === 0) return false;
return Object.entries(svcSelector).every(([k, v]) => selectorLabels[k] === v);
};
// 关联的 Service(按 selector 匹配 Deployment)
const matchedSvcs = services.filter(s => selectorMatches(s.selector));
// 关联的 Ingress(backend 指向 matchedSvcs 之一)
const matchedSvcNames = new Set(matchedSvcs.map(s => s.name));
const matchedIngs = ingresses.filter(i =>
i.rules.some(r => r.paths.some(p => matchedSvcNames.has(p.backend.service)))
);
// 创建 Service
const createService = async () => {
const name = svcForm.name.trim() || `${deploymentName}-svc`;
const port = Number(svcForm.port) || 80;
const targetPort = svcForm.targetPort.trim() || svcForm.port;
const sport: any = {
name: svcForm.portName || 'http',
port,
targetPort: /^\d+$/.test(String(targetPort)) ? Number(targetPort) : targetPort,
protocol: svcForm.protocol || 'TCP',
};
if ((svcForm.type === 'NodePort' || svcForm.type === 'LoadBalancer') && svcForm.nodePort) {
sport.nodePort = Number(svcForm.nodePort);
}
const manifest: any = {
apiVersion: 'v1',
kind: 'Service',
metadata: {
name,
namespace,
labels: { app: deploymentName },
},
spec: {
type: svcForm.type,
selector: selectorLabels,
ports: [sport],
},
};
const json = JSON.stringify(manifest, null, 2);
try {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'services', name, json);
toast({ title: 'Service 已创建', description: name });
setShowCreate(null);
load();
} catch (e: any) {
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
}
};
// 创建 Ingress
const createIngress = async () => {
const name = ingForm.name.trim() || `${deploymentName}-ingress`;
const targetSvc = matchedSvcs[0]?.name || `${deploymentName}-svc`;
const port = Number(ingForm.svcPort) || 80;
const manifest: any = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name,
namespace,
labels: { app: deploymentName },
},
spec: {
ingressClassName: ingForm.className || undefined,
rules: [{
host: ingForm.host || undefined,
http: {
paths: [{
path: ingForm.path || '/',
pathType: ingForm.pathType || 'Prefix',
backend: {
service: { name: targetSvc, port: { number: port } },
},
}],
},
}],
},
};
if (!ingForm.className) delete manifest.spec.ingressClassName;
const json = JSON.stringify(manifest, null, 2);
try {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'ingresses', name, json);
toast({ title: 'Ingress 已创建', description: name });
setShowCreate(null);
load();
} catch (e: any) {
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
}
};
// 删除关联的 Service 或 Ingress(M9 补全)
const deleteResource = async (kind: 'service' | 'ingress', name: string) => {
if (!window.confirm(`确认删除 ${kind === 'service' ? 'Service' : 'Ingress'} 「${name}」?此操作不可撤销。`)) return;
try {
const resource = kind === 'service' ? 'services' : 'ingresses';
await kubernetesApi.deleteResourceYaml(clusterCode, namespace, resource, name);
toast({ title: '已删除', description: `${kind === 'service' ? 'Service' : 'Ingress'} ${name} 已删除` });
load();
} catch (e: any) {
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
}
};
if (loading) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-12 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-slate-500" />
<p className="text-slate-500 mt-3 text-sm">加载中...</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{/* Selector 提示 */}
<Card className="bg-slate-900/50 border-slate-800">
<CardContent className="p-3">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Network className="h-3.5 w-3.5 text-blue-400" />
<span>Deployment selector:</span>
<div className="flex flex-wrap gap-1">
{Object.entries(selectorLabels).map(([k, v]) => (
<code key={k} className="bg-slate-800 px-1.5 py-0.5 rounded text-slate-300 text-[11px]">{k}={v}</code>
))}
{Object.keys(selectorLabels).length === 0 && (
<span className="text-slate-600 italic">(无 selector)</span>
)}
</div>
<Button size="sm" variant="ghost" className="h-6 ml-auto text-xs text-slate-400" onClick={load}>
<RefreshCw className="h-3 w-3 mr-1" />刷新
</Button>
</div>
</CardContent>
</Card>
{/* Services */}
<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">
<Network className="h-4 w-4 text-blue-400" />
Services
<Badge variant="outline" className="border-slate-700 text-slate-400">{matchedSvcs.length}/{services.length}</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs"
onClick={() => { setSvcForm(s => ({ ...s, name: `${deploymentName}-svc`, port: '80', targetPort: '80' })); setShowCreate('service'); }}>
<Plus className="h-3 w-3 mr-1" />创建 Service
</Button>
</div>
<p className="text-xs text-slate-500 mt-1">
高亮的是 selector 匹配此 Deployment 的 Service({matchedSvcs.length} 个关联 / {services.length} 个总数)
</p>
</CardHeader>
<CardContent className="pt-0">
{services.length === 0 ? (
<div className="text-center py-6 text-slate-500 text-sm border border-dashed border-slate-800 rounded-lg">
<Network className="h-8 w-8 mx-auto mb-2 text-slate-700" />
当前命名空间暂无 Service
</div>
) : (
<div className="space-y-2">
{services.map(svc => {
const matched = selectorMatches(svc.selector);
return (
<div key={svc.name} className={`rounded-lg border p-3 ${matched ? 'border-blue-700/50 bg-blue-950/20' : 'border-slate-800 bg-slate-950/30'}`}>
<div className="flex items-center gap-2 flex-wrap">
{matched ? <CheckCircle className="h-4 w-4 text-blue-400" /> : <Network className="h-4 w-4 text-slate-500" />}
<span className="text-sm font-semibold text-slate-200">{svc.name}</span>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{svc.type}</Badge>
{matched && <Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-[10px]">关联</Badge>}
<code className="text-[11px] text-slate-500 ml-auto">{svc.clusterIP}</code>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400"
onClick={() => setYamlModal({ kind: 'service', name: svc.name })}>
<ExternalLink className="h-3 w-3 mr-1" />YAML
</Button>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
title="删除 Service"
onClick={() => deleteResource('service', svc.name)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* Ports */}
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
{svc.ports.map((p, i) => (
<code key={i} className="bg-slate-800/60 px-1.5 py-0.5 rounded text-slate-300">
{p.port}:{p.targetPort}{p.nodePort ? `/NodePort:${p.nodePort}` : ''}/{p.protocol}
</code>
))}
</div>
{/* Selector */}
<div className="mt-1 flex flex-wrap gap-1 text-[10px]">
<span className="text-slate-600">selector:</span>
{Object.entries(svc.selector).map(([k, v]) => (
<code key={k} className="text-slate-500">{k}={v}</code>
))}
</div>
</div>
);
})}
</div>
)}
{/* 创建 Service 表单 */}
{showCreate === 'service' && (
<div className="mt-3 border border-blue-800/50 rounded-lg p-3 bg-blue-950/20 space-y-2">
<div className="text-xs font-semibold text-blue-300 flex items-center gap-1.5">
<Plus className="h-3 w-3" />创建新 Service
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-slate-500 text-[11px]">名称</Label>
<Input value={svcForm.name} onChange={e => setSvcForm(s => ({ ...s, name: e.target.value }))}
placeholder={`${deploymentName}-svc`}
className="h-7 mt-0.5 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">类型</Label>
<select value={svcForm.type} onChange={e => setSvcForm(s => ({ ...s, type: e.target.value }))}
className="h-7 mt-0.5 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2">
<option value="ClusterIP">ClusterIP</option>
<option value="NodePort">NodePort</option>
<option value="LoadBalancer">LoadBalancer</option>
</select>
</div>
<div>
<Label className="text-slate-500 text-[11px]">port (Service 端口)</Label>
<Input type="number" value={svcForm.port} onChange={e => setSvcForm(s => ({ ...s, port: e.target.value }))}
className="h-7 mt-0.5 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">targetPort (容器端口)</Label>
<Input value={svcForm.targetPort} onChange={e => setSvcForm(s => ({ ...s, targetPort: e.target.value }))}
className="h-7 mt-0.5 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
{(svcForm.type === 'NodePort' || svcForm.type === 'LoadBalancer') && (
<div>
<Label className="text-slate-500 text-[11px]">nodePort (可选)</Label>
<Input type="number" value={svcForm.nodePort} onChange={e => setSvcForm(s => ({ ...s, nodePort: e.target.value }))}
placeholder="30000-32767"
className="h-7 mt-0.5 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="ghost" className="h-7 text-xs text-slate-400" onClick={() => setShowCreate(null)}>取消</Button>
<Button size="sm" className="h-7 bg-blue-600 hover:bg-blue-700 text-xs" onClick={createService}>创建</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Ingresses */}
<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">
<GitBranch className="h-4 w-4 text-purple-400" />
Ingresses (应用路由)
<Badge variant="outline" className="border-slate-700 text-slate-400">{matchedIngs.length}/{ingresses.length}</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs"
onClick={() => { setIngForm(s => ({ ...s, name: `${deploymentName}-ingress`, svcPort: String(matchedSvcs[0]?.ports[0]?.port || 80) })); setShowCreate('ingress'); }}>
<Plus className="h-3 w-3 mr-1" />创建 Ingress
</Button>
</div>
<p className="text-xs text-slate-500 mt-1">
backend 指向关联 Service 的 Ingress 自动高亮
</p>
</CardHeader>
<CardContent className="pt-0">
{ingresses.length === 0 ? (
<div className="text-center py-6 text-slate-500 text-sm border border-dashed border-slate-800 rounded-lg">
<GitBranch className="h-8 w-8 mx-auto mb-2 text-slate-700" />
当前命名空间暂无 Ingress
</div>
) : (
<div className="space-y-2">
{ingresses.map(ing => {
const matched = matchedIngs.includes(ing);
return (
<div key={ing.name} className={`rounded-lg border p-3 ${matched ? 'border-purple-700/50 bg-purple-950/20' : 'border-slate-800 bg-slate-950/30'}`}>
<div className="flex items-center gap-2 flex-wrap">
{matched ? <CheckCircle className="h-4 w-4 text-purple-400" /> : <GitBranch className="h-4 w-4 text-slate-500" />}
<span className="text-sm font-semibold text-slate-200">{ing.name}</span>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{ing.className}</Badge>
{matched && <Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-[10px]">关联</Badge>}
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400 ml-auto"
onClick={() => setYamlModal({ kind: 'ingress', name: ing.name })}>
<ExternalLink className="h-3 w-3 mr-1" />YAML
</Button>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
title="删除 Ingress"
onClick={() => deleteResource('ingress', ing.name)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* Rules */}
<div className="mt-2 space-y-1 text-[11px]">
{ing.rules.map((r, i) => (
<div key={i} className="flex items-start gap-1.5">
<Server className="h-3 w-3 text-slate-500 mt-0.5" />
<code className="text-slate-300">{r.host || '*'}</code>
{r.paths.map((p, j) => (
<code key={j} className="bg-slate-800/60 px-1.5 py-0.5 rounded text-slate-400">
{p.path} → {p.backend.service}:{p.backend.port}
</code>
))}
</div>
))}
</div>
</div>
);
})}
</div>
)}
{/* 创建 Ingress 表单 */}
{showCreate === 'ingress' && (
<div className="mt-3 border border-purple-800/50 rounded-lg p-3 bg-purple-950/20 space-y-2">
<div className="text-xs font-semibold text-purple-300 flex items-center gap-1.5">
<Plus className="h-3 w-3" />创建新 Ingress
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-slate-500 text-[11px]">名称</Label>
<Input value={ingForm.name} onChange={e => setIngForm(s => ({ ...s, name: e.target.value }))}
placeholder={`${deploymentName}-ingress`}
className="h-7 mt-0.5 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">ingressClassName</Label>
<Input value={ingForm.className} onChange={e => setIngForm(s => ({ ...s, className: e.target.value }))}
placeholder="nginx"
className="h-7 mt-0.5 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">host (域名)</Label>
<Input value={ingForm.host} onChange={e => setIngForm(s => ({ ...s, host: e.target.value }))}
placeholder="app.example.com"
className="h-7 mt-0.5 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">path</Label>
<Input value={ingForm.path} onChange={e => setIngForm(s => ({ ...s, path: e.target.value }))}
placeholder="/"
className="h-7 mt-0.5 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">Service 端口</Label>
<Input type="number" value={ingForm.svcPort} onChange={e => setIngForm(s => ({ ...s, svcPort: e.target.value }))}
className="h-7 mt-0.5 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">pathType</Label>
<select value={ingForm.pathType} onChange={e => setIngForm(s => ({ ...s, pathType: e.target.value }))}
className="h-7 mt-0.5 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2">
<option value="Prefix">Prefix</option>
<option value="Exact">Exact</option>
<option value="ImplementationSpecific">ImplementationSpecific</option>
</select>
</div>
</div>
<p className="text-[10px] text-slate-500">
后端将指向此 Deployment 关联的第一个 Service: <code className="text-slate-400">{matchedSvcs[0]?.name || `${deploymentName}-svc`}</code>
</p>
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="ghost" className="h-7 text-xs text-slate-400" onClick={() => setShowCreate(null)}>取消</Button>
<Button size="sm" className="h-7 bg-purple-600 hover:bg-purple-700 text-xs" onClick={createIngress}>创建</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* 底部说明 */}
<div className="flex items-start gap-2 text-xs text-slate-500 bg-slate-950/40 border border-slate-800 rounded-lg p-3">
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0 mt-0.5" />
<div>
<p>Service / Ingress 是独立的 K8s 资源,保存即创建,<strong className="text-slate-400">不会修改 Deployment 本身</strong>。</p>
<p className="mt-1">编辑 Deployment manifest 请使用「基本信息」「容器信息」「存储挂载」「高级设置」Tab。</p>
</div>
</div>
{/* YAML 编辑弹窗 */}
{yamlModal && (
<YamlViewer
clusterCode={clusterCode}
namespace={namespace}
resource={yamlModal.kind === 'service' ? 'services' : 'ingresses'}
name={yamlModal.name}
cleanBeforeEdit
onClose={() => setYamlModal(null)}
onSaved={load}
/>
)}
</div>
);
}
@@ -0,0 +1,437 @@
// DeploymentEditStorage.tsx — 编辑器「存储挂载」Tab (M8)
// 编辑内容:spec.template.spec.volumes[] — 数据卷定义
// 支持 6 种常用 volume 类型:emptyDir / hostPath / configMap / secret / persistentVolumeClaim / nfs
// 作者: Logan <admin@attacker.club>
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 { Badge } from '@/components/ui/badge';
import {
Plus, Trash2, Save, Loader2, HardDrive, Database, Folder, Lock,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/services/api';
interface Props {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onCancel: () => void;
}
/** 单个 volume 的编辑态 */
interface VolumeForm {
name: string;
type: 'emptyDir' | 'hostPath' | 'configMap' | 'secret' | 'persistentVolumeClaim' | 'nfs';
// 各类型字段(仅对应类型有效)
medium?: string; // emptyDir.medium: "" | "Memory"
sizeLimit?: string; // emptyDir.sizeLimit
path?: string; // hostPath.path
pathType?: string; // hostPath.pathType: Directory | File | Socket | ...
configMapName?: string; // configMap.name
secretName?: string; // secret.secretName
pvcName?: string; // persistentVolumeClaim.claimName
nfsServer?: string; // nfs.server
nfsPath?: string; // nfs.path
readOnly?: boolean; // pvc/nfs readOnly
items?: string; // configMap/secret items (JSON 文本,高级用户)
}
/** 从 K8s volume 对象提取为 form */
function volumeToForm(v: any): VolumeForm {
if (!v || typeof v !== 'object') return { name: '', type: 'emptyDir' };
const name = v.name || '';
if (v.emptyDir) {
return {
name, type: 'emptyDir',
medium: v.emptyDir.medium || '',
sizeLimit: v.emptyDir.sizeLimit || '',
};
}
if (v.hostPath) {
return {
name, type: 'hostPath',
path: v.hostPath.path || '',
pathType: v.hostPath.type || 'Directory',
};
}
if (v.configMap) {
return {
name, type: 'configMap',
configMapName: v.configMap.name || '',
items: v.configMap.items ? JSON.stringify(v.configMap.items) : '',
};
}
if (v.secret) {
return {
name, type: 'secret',
secretName: v.secret.secretName || '',
items: v.secret.items ? JSON.stringify(v.secret.items) : '',
};
}
if (v.persistentVolumeClaim) {
return {
name, type: 'persistentVolumeClaim',
pvcName: v.persistentVolumeClaim.claimName || '',
readOnly: !!v.persistentVolumeClaim.readOnly,
};
}
if (v.nfs) {
return {
name, type: 'nfs',
nfsServer: v.nfs.server || '',
nfsPath: v.nfs.path || '',
readOnly: !!v.nfs.readOnly,
};
}
// 未识别类型 — fallback 为 emptyDir 占位
return { name, type: 'emptyDir' };
}
/** 将 form 转回 K8s volume 对象 */
function formToVolume(f: VolumeForm): any {
const v: any = { name: f.name.trim() };
if (!v.name) return null; // 无名 volume 跳过
switch (f.type) {
case 'emptyDir': {
const ed: any = {};
if (f.medium) ed.medium = f.medium;
if (f.sizeLimit) ed.sizeLimit = f.sizeLimit;
v.emptyDir = ed;
break;
}
case 'hostPath': {
v.hostPath = {
path: f.path || '',
type: f.pathType || 'Directory',
};
break;
}
case 'configMap': {
v.configMap = { name: f.configMapName || '' };
if (f.items) {
try { v.configMap.items = JSON.parse(f.items); } catch {}
}
break;
}
case 'secret': {
v.secret = { secretName: f.secretName || '' };
if (f.items) {
try { v.secret.items = JSON.parse(f.items); } catch {}
}
break;
}
case 'persistentVolumeClaim': {
v.persistentVolumeClaim = {
claimName: f.pvcName || '',
...(f.readOnly ? { readOnly: true } : {}),
};
break;
}
case 'nfs': {
v.nfs = {
server: f.nfsServer || '',
path: f.nfsPath || '',
...(f.readOnly ? { readOnly: true } : {}),
};
break;
}
}
return v;
}
const TYPE_META: Record<VolumeForm['type'], { label: string; icon: typeof HardDrive; color: string }> = {
emptyDir: { label: 'emptyDir', icon: Folder, color: 'text-slate-300' },
hostPath: { label: 'hostPath', icon: HardDrive, color: 'text-amber-400' },
configMap: { label: 'configMap', icon: Folder, color: 'text-blue-400' },
secret: { label: 'secret', icon: Lock, color: 'text-rose-400' },
persistentVolumeClaim: { label: 'PVC', icon: Database, color: 'text-emerald-400' },
nfs: { label: 'NFS', icon: HardDrive, color: 'text-purple-400' },
};
export function DeploymentEditStorage({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: Props) {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest);
const [volumes, setVolumes] = useState<VolumeForm[]>([]);
const [rawManifest, setRawManifest] = useState<any>(manifest || {});
// 容器已声明的 volumeMounts(只读展示,便于用户对齐 volume name)
const [mountSummary, setMountSummary] = useState<Array<{ container: string; mounts: string[] }>>([]);
useEffect(() => {
const m = manifest;
if (!m) { setLoading(false); return; }
setRawManifest(m);
const podSpec = m?.spec?.template?.spec || {};
const vols: any[] = Array.isArray(podSpec.volumes) ? podSpec.volumes : [];
setVolumes(vols.map(volumeToForm));
// 汇总容器 volumeMounts
const allContainers = [
...(podSpec.containers || []),
...(podSpec.initContainers || []),
];
const summary = allContainers.map((c: any) => ({
container: c.name || '(unnamed)',
mounts: (Array.isArray(c.volumeMounts) ? c.volumeMounts : [])
.map((vm: any) => `${vm.name}:${vm.mountPath}${vm.readOnly ? '(ro)' : ''}`),
})).filter((s: any) => s.mounts.length > 0);
setMountSummary(summary);
setLoading(false);
}, [manifest]);
const addVolume = () => setVolumes(prev => [...prev, { name: '', type: 'emptyDir' }]);
const removeVolume = (i: number) => setVolumes(prev => prev.filter((_, idx) => idx !== i));
const updateVolume = (i: number, patch: Partial<VolumeForm>) =>
setVolumes(prev => prev.map((v, idx) => (idx === i ? { ...v, ...patch } : v)));
const save = async () => {
setSaving(true);
try {
const base = JSON.parse(JSON.stringify(rawManifest));
base.spec = base.spec || {};
base.spec.template = base.spec.template || {};
base.spec.template.spec = base.spec.template.spec || {};
const k8sVols = volumes.map(formToVolume).filter(Boolean);
if (k8sVols.length > 0) {
base.spec.template.spec.volumes = k8sVols;
} else {
delete base.spec.template.spec.volumes;
}
const json = JSON.stringify(base, null, 2);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, json);
toast({ title: '存储配置已保存', description: `${deploymentName} volumes 更新已提交` });
onSaved();
} catch (e: any) {
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setSaving(false); }
};
if (loading) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-12 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-slate-500" />
<p className="text-slate-500 mt-3 text-sm">加载中...</p>
</CardContent>
</Card>
);
}
if (!manifest) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8 text-center text-slate-500">
未获取到 manifest 数据,请使用 YAML 编辑模式直接修改。
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{/* 数据卷列表 */}
<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">
<Database className="h-4 w-4 text-emerald-400" />
数据卷 (Volumes)
<Badge variant="outline" className="border-slate-700 text-slate-400">{volumes.length}</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={addVolume}>
<Plus className="h-3 w-3 mr-1" />添加数据卷
</Button>
</div>
<p className="text-xs text-slate-500 mt-1">
Pod 级别的卷定义,容器通过 <code className="text-slate-400">volumeMounts</code> 引用这些卷名。
</p>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{volumes.length === 0 ? (
<div className="text-center py-8 text-slate-500 text-sm border border-dashed border-slate-800 rounded-lg">
<Database className="h-8 w-8 mx-auto mb-2 text-slate-700" />
未配置数据卷,点击右上角「添加数据卷」
</div>
) : volumes.map((vol, i) => {
const meta = TYPE_META[vol.type];
const Icon = meta.icon;
return (
<div key={i} className="bg-slate-950/50 border border-slate-800 rounded-lg p-3 space-y-3">
<div className="flex items-center gap-3">
<Icon className={`h-4 w-4 ${meta.color}`} />
<Input
value={vol.name}
onChange={e => updateVolume(i, { name: e.target.value })}
placeholder="volume-name"
className="flex-1 h-8 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono"
/>
<select
value={vol.type}
onChange={e => updateVolume(i, { type: e.target.value as VolumeForm['type'] })}
className="h-8 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2"
>
{Object.entries(TYPE_META).map(([t, m]) => (
<option key={t} value={t}>{m.label}</option>
))}
</select>
<Button size="icon" variant="ghost" className="h-7 w-7 text-red-400 hover:text-red-300"
onClick={() => removeVolume(i)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* 类型相关字段 */}
{vol.type === 'emptyDir' && (
<div className="grid grid-cols-2 gap-3 pl-7">
<div>
<Label className="text-slate-500 text-xs">medium (留空=磁盘, Memory=tmpfs)</Label>
<Input value={vol.medium || ''} onChange={e => updateVolume(i, { medium: e.target.value })}
placeholder="" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-xs">sizeLimit (例: 512Mi)</Label>
<Input value={vol.sizeLimit || ''} onChange={e => updateVolume(i, { sizeLimit: e.target.value })}
placeholder="512Mi" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
</div>
)}
{vol.type === 'hostPath' && (
<div className="grid grid-cols-2 gap-3 pl-7">
<div>
<Label className="text-slate-500 text-xs">主机路径 path</Label>
<Input value={vol.path || ''} onChange={e => updateVolume(i, { path: e.target.value })}
placeholder="/data/app" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-xs">pathType</Label>
<select value={vol.pathType || 'Directory'} onChange={e => updateVolume(i, { pathType: e.target.value })}
className="h-7 mt-1 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2">
<option value="Directory">Directory</option>
<option value="File">File</option>
<option value="Socket">Socket</option>
<option value="CharDevice">CharDevice</option>
<option value="BlockDevice">BlockDevice</option>
<option value="">(空)</option>
</select>
</div>
</div>
)}
{vol.type === 'configMap' && (
<div className="pl-7">
<Label className="text-slate-500 text-xs">ConfigMap 名称</Label>
<Input value={vol.configMapName || ''} onChange={e => updateVolume(i, { configMapName: e.target.value })}
placeholder="app-config" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Label className="text-slate-500 text-xs mt-2 block">items (JSON, 可选)</Label>
<Input value={vol.items || ''} onChange={e => updateVolume(i, { items: e.target.value })}
placeholder='[{"key":"app.conf","path":"app.conf"}]'
className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
)}
{vol.type === 'secret' && (
<div className="pl-7">
<Label className="text-slate-500 text-xs">Secret 名称</Label>
<Input value={vol.secretName || ''} onChange={e => updateVolume(i, { secretName: e.target.value })}
placeholder="app-tls" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<Label className="text-slate-500 text-xs mt-2 block">items (JSON, 可选)</Label>
<Input value={vol.items || ''} onChange={e => updateVolume(i, { items: e.target.value })}
placeholder='[{"key":"tls.crt","path":"tls.crt"}]'
className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
)}
{vol.type === 'persistentVolumeClaim' && (
<div className="grid grid-cols-2 gap-3 pl-7">
<div>
<Label className="text-slate-500 text-xs">PVC claimName</Label>
<Input value={vol.pvcName || ''} onChange={e => updateVolume(i, { pvcName: e.target.value })}
placeholder="data-pvc" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
<input type="checkbox" checked={!!vol.readOnly}
onChange={e => updateVolume(i, { readOnly: e.target.checked })}
className="accent-blue-500" />
readOnly
</label>
</div>
</div>
)}
{vol.type === 'nfs' && (
<div className="grid grid-cols-3 gap-3 pl-7">
<div>
<Label className="text-slate-500 text-xs">server</Label>
<Input value={vol.nfsServer || ''} onChange={e => updateVolume(i, { nfsServer: e.target.value })}
placeholder="10.10.10.100" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div>
<Label className="text-slate-500 text-xs">path</Label>
<Input value={vol.nfsPath || ''} onChange={e => updateVolume(i, { nfsPath: e.target.value })}
placeholder="/export/data" className="h-7 mt-1 bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
<input type="checkbox" checked={!!vol.readOnly}
onChange={e => updateVolume(i, { readOnly: e.target.checked })}
className="accent-blue-500" />
readOnly
</label>
</div>
</div>
)}
</div>
);
})}
</CardContent>
</Card>
{/* 容器 volumeMounts 汇总(只读) */}
{mountSummary.length > 0 && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Folder className="h-4 w-4 text-blue-400" />
容器挂载点 (volumeMounts)
</CardTitle>
<p className="text-xs text-slate-500 mt-1">
容器侧已声明的挂载,请在「容器信息」Tab 修改。
</p>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{mountSummary.map((s, i) => (
<div key={i} className="flex items-start gap-2 text-xs">
<Badge variant="outline" className="border-slate-700 text-slate-400 font-mono shrink-0">
{s.container}
</Badge>
<div className="flex flex-wrap gap-1.5">
{s.mounts.map((m, j) => (
<code key={j} className="bg-slate-800 px-1.5 py-0.5 rounded text-slate-300 text-[11px]">{m}</code>
))}
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* 操作栏 */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onCancel}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={saving} onClick={save}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
保存
</Button>
</div>
</div>
);
}
@@ -17,7 +17,7 @@ import {
Trash2, FileText, Terminal, RefreshCw, Loader2, X,
ChevronDown, Cpu, Network, GitBranch, Info,
CheckCircle, AlertTriangle, XCircle,
Pause, Play, Rocket, Container, Settings,
Pause, Play, Rocket, Container, Settings, Database, HardDrive,
} from 'lucide-react';
import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
@@ -26,6 +26,9 @@ import { YamlViewer } from './YamlViewer';
import { DeploymentView } from './DeploymentView';
import { DeploymentEditContainers } from './DeploymentEditContainers';
import { DeploymentEditBasic } from './DeploymentEditBasic';
import { DeploymentEditStorage } from './DeploymentEditStorage';
import { DeploymentEditAdvanced } from './DeploymentEditAdvanced';
import { DeploymentEditService } from './DeploymentEditService';
type WorkloadMode = 'view' | 'edit' | 'yaml';
@@ -550,9 +553,9 @@ function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, o
const tabs: Array<{ key: EditTab; label: string; icon: typeof Settings; available: boolean; badge?: string }> = [
{ key: 'basic', label: '基本信息', icon: Settings, available: true },
{ key: 'containers', label: '容器信息', icon: Container, available: true },
{ key: 'storage', label: '存储挂载', icon: Container, available: false, badge: 'M8' },
{ key: 'advanced', label: '高级设置', icon: Settings, available: false, badge: 'M8' },
{ key: 'service', label: '服务/应用路由', icon: Network, available: false, badge: 'M9' },
{ key: 'storage', label: '存储挂载', icon: Database, available: true },
{ key: 'advanced', label: '高级设置', icon: Settings, available: true },
{ key: 'service', label: '服务/应用路由', icon: Network, available: true },
];
return (
@@ -607,6 +610,36 @@ function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, o
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'storage' ? (
/* M8: 存储挂载 — volumes + volumeMounts 汇总 */
<DeploymentEditStorage
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'advanced' ? (
/* M8: 高级设置 — 更新策略/调度/DNS/安全上下文/终止宽限期 */
<DeploymentEditAdvanced
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'service' ? (
/* M9: 服务/应用路由 — 关联 Service + Ingress,可创建 */
<DeploymentEditService
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : null}
</div>
);
@@ -27,6 +27,7 @@ import {
Trash2,
Search,
Container,
Download,
} from 'lucide-react';
import { kubernetesApi } from '@/services/api';
import { toast } from 'sonner';
@@ -49,6 +50,11 @@ export function KubernetesClusters() {
const [kubectlCluster, setKubectlCluster] = useState<{id: number; name: string} | null>(null);
const [kubectlTerminalOpen, setKubectlTerminalOpen] = useState(false);
// 导出资源弹窗
const [exportCluster, setExportCluster] = useState<any>(null);
const [exportNs, setExportNs] = useState('default');
const [exporting, setExporting] = useState(false);
const loadData = async () => {
try {
setLoading(true);
@@ -104,6 +110,23 @@ export function KubernetesClusters() {
}
};
const handleExport = async () => {
if (!exportCluster) return;
const code = exportCluster.code || String(exportCluster.id);
if (!exportNs.trim()) { toast.error('请输入命名空间'); return; }
try {
setExporting(true);
toast.info(`正在导出 ${exportNs} 命名空间的资源...`);
await kubernetesApi.exportResources(code, exportNs.trim());
toast.success('导出完成,已开始下载');
setExportCluster(null);
} catch (e: any) {
toast.error(e?.message || '导出失败');
} finally {
setExporting(false);
}
};
const filteredClusters = clusters.filter(
(cluster) =>
(cluster.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -331,6 +354,17 @@ export function KubernetesClusters() {
<Container className="h-4 w-4 mr-2" />
kubectl
</DropdownMenuItem>
<DropdownMenuItem
className="text-blue-400 focus:bg-slate-700 focus:text-blue-300"
onClick={(e) => {
e.stopPropagation();
setExportCluster(cluster);
setExportNs('default');
}}
>
<Download className="h-4 w-4 mr-2" />
导出资源
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
@@ -432,6 +466,32 @@ export function KubernetesClusters() {
clusterName={kubectlCluster.name}
/>
)}
{/* 导出资源弹窗 */}
{exportCluster && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setExportCluster(null)}>
<div className="bg-slate-900 border border-slate-700 rounded-lg p-5 w-96 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-base font-semibold text-slate-100 mb-1">导出集群资源</h3>
<p className="text-xs text-slate-400 mb-4">
导出 {exportCluster.name} 的 Deployment + Service + Ingress + ConfigMap + Secret 为 tar.gz
</p>
<label className="text-xs text-slate-300 mb-1.5 block">命名空间</label>
<Input
value={exportNs}
onChange={(e) => setExportNs(e.target.value)}
placeholder="default"
className="bg-slate-800 border-slate-700 text-slate-100 mb-4"
onKeyDown={(e) => e.key === 'Enter' && handleExport()}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setExportCluster(null)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleExport} disabled={exporting}>
{exporting ? '导出中...' : '确认导出'}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -69,6 +69,10 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
}
};
// 视图模式:当 cleanBeforeEdit 时,只读视图也默认展示精简版(剥离 status/managedFields/uid/resourceVersion)
// 用户可点击「原始」按钮切换回未处理的完整服务端输出
const [showRaw, setShowRaw] = useState(false);
const saveYaml = async () => {
setSaving(true);
try {
@@ -91,7 +95,10 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
useEffect(() => { loadYaml(); }, []);
const display = editing ? editYaml : rawYaml;
// 编辑态用 editYaml;只读态根据 effectiveClean + showRaw 决定是否清理
const display = editing
? editYaml
: (effectiveClean && !showRaw ? cleanYAML(rawYaml) : rawYaml);
// CodeMirror 主题扩展
const extensions = useMemo(() => [
@@ -113,11 +120,17 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
{copied ? '已复制' : '复制'}
</Button>
{effectiveClean && !editing && (
<Button variant="outline" size="sm" className={`h-7 text-xs ${showRaw ? 'border-slate-700 text-slate-500' : 'border-emerald-700/50 text-emerald-400'}`}
onClick={() => setShowRaw(v => !v)} title="切换 精简/原始 视图">
<Sparkles className="h-3 w-3 mr-1" />{showRaw ? '原始' : '精简'}
</Button>
)}
{!editing ? (
<>
{effectiveClean && (
<Button variant="outline" size="sm" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={enterEdit} title="清理 status / managedFields 等只读字段后编辑">
<Sparkles className="h-3 w-3 mr-1 text-amber-400" />清理并编辑
<Sparkles className="h-3 w-3 mr-1 text-amber-400" />编辑
</Button>
)}
{!effectiveClean && (
@@ -182,6 +195,12 @@ export function YamlViewer({ clusterCode, namespace, resource, name, onClose, on
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
{copied ? '已复制' : '复制'}
</Button>
{effectiveClean && !editing && (
<Button variant="outline" size="sm" className={`h-8 text-xs ${showRaw ? 'border-slate-700 text-slate-500' : 'border-emerald-700/50 text-emerald-400'}`}
onClick={() => setShowRaw(v => !v)} title="切换 精简/原始 视图">
<Sparkles className="h-3 w-3 mr-1" />{showRaw ? '原始' : '精简'}
</Button>
)}
{!editing ? (
<>
{effectiveClean && (