Files
aiops/frontend/src/components/kubernetes/DeploymentEditService.tsx
T

546 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(兼容两种来源)
// full K8s manifest: spec.selector.matchLabels
// DepSummary 降级: selector (顶层)
const sel = manifest?.spec?.selector?.matchLabels || manifest?.selector || {};
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) => {
// 兼容两种数据源:完整 K8s spec 或后端 SvcSummary 顶层字段
const spec = s.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,
})) : Array.isArray(s.portList) ? s.portList.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 || s.type || 'ClusterIP',
clusterIP: spec.clusterIP || s.cluster_ip || '<none>',
externalIP: Array.isArray(spec.externalIPs) ? spec.externalIPs.join(',') : '',
ports,
// selector: 优先 spec.selector(完整 K8s),降级 s.selector(SvcSummary 顶层)
selector: spec.selector || s.selector || {},
age: s.age || s.created_at || '',
raw: s,
};
});
const ingSummaries: IngSummary[] = allIngs.map((i: any) => {
// 兼容:完整 K8s spec 或后端 IngSummary 顶层字段
const spec = i.spec || {};
// 规则来源:spec.rules (K8s) 或 i.rules (IngSummary,含 backendService/backendPort)
const rawRules: any[] = Array.isArray(spec.rules) ? spec.rules : (Array.isArray(i.rules) ? i.rules : []);
const rules = rawRules.map((r: any) => ({
host: r.host || '',
paths: (r.http?.paths || r.paths || []).map((p: any) => ({
path: p.path || '/',
backend: {
// K8s 格式: p.backend.service.name / IngSummary 格式: p.backendService
service: p.backend?.service?.name || p.backendService || '',
port: p.backend?.service?.port?.number || p.backend?.service?.port?.name || p.backendPort || '',
},
})),
}));
return {
name: i.name || i.metadata?.name || '',
className: spec.ingressClassName || i.ingressClass || '<none>',
hosts: rules.map((r: any) => r.host).filter(Boolean),
rules,
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} 个关联</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
</p>
</CardHeader>
<CardContent className="pt-0">
{matchedSvcs.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">
{matchedSvcs.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>
);
}