// DeploymentEditService.tsx — 编辑器「服务/应用路由」Tab (M9) // 展示与当前 Deployment 关联的 Service / Ingress,并提供创建入口 // 作者: Logan 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, CheckCircle, Server, Trash2, Minus, Zap, } 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 SvcSummary { name: string; type: string; clusterIP: string; externalIP: string; ports: Array<{ port: number; targetPort: any; protocol: string; nodePort?: number; name?: string }>; selector: Record; age: string; raw: any; } interface IngSummary { name: string; className: string; hosts: string[]; rules: Array<{ host: string; paths: Array<{ path: string; backend: { service: string; port: unknown} }> }>; raw: any; } // 3 种常用端口预设 const PORT_PRESETS = [ { label: 'HTTP', protocol: 'TCP' as const, port: 8080, targetPort: 8080, name: 'http' }, { label: 'WebSocket', protocol: 'TCP' as const, port: 8001, targetPort: 8002, name: 'ws' }, { label: 'gRPC', protocol: 'TCP' as const, port: 8002, targetPort: 8002, name: 'grpc' }, ]; interface _SvcPortForm { name: string; port: string; targetPort: string; protocol: 'TCP' | 'UDP'; nodePort: string; } 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([]); const [ingresses, setIngresses] = useState([]); const [selectorLabels, setSelectorLabels] = useState>({}); 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: string; type: string; ports: Array<{ name: string; port: string; targetPort: string; protocol: 'TCP' | 'UDP'; nodePort: string }>; }>({ name: '', type: 'ClusterIP', ports: [{ name: 'http', port: '8080', targetPort: '8080', protocol: 'TCP', nodePort: '' }], }); // 创建 Ingress 表单 const [ingForm, setIngForm] = useState({ name: '', className: 'nginx', host: '', path: '/', pathType: 'Prefix', svcPort: '80', }); // ──── 端口操作 ──── const addPort = (preset?: typeof PORT_PRESETS[number]) => { setSvcForm(s => ({ ...s, ports: [...s.ports, preset ? { name: preset.name, port: String(preset.port), targetPort: String(preset.targetPort), protocol: preset.protocol as 'TCP' | 'UDP', nodePort: '' } : { name: '', port: '', targetPort: '', protocol: 'TCP' as const, nodePort: '' } ], })); }; const updatePort = (i: number, field: string, value: string) => { setSvcForm(s => ({ ...s, ports: s.ports.map((p, idx) => idx === i ? { ...p, [field]: value } : p), })); }; const removePort = (i: number) => { if (svcForm.ports.length <= 1) return; setSvcForm(s => ({ ...s, ports: s.ports.filter((_, idx) => idx !== i) })); }; const load = useCallback(async () => { setLoading(true); try { 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) => { 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 || '', externalIP: Array.isArray(spec.externalIPs) ? spec.externalIPs.join(',') : '', ports, selector: spec.selector || s.selector || {}, age: s.age || s.created_at || '', raw: s, }; }); const ingSummaries: IngSummary[] = allIngs.map(( i: any) => { const spec = i.spec || {}; const rawRules: Record[] = 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: { 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 || '', 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]); const selectorMatches = (svcSelector: Record): boolean => { if (Object.keys(svcSelector).length === 0) return false; return Object.entries(svcSelector).every(([k, v]) => selectorLabels[k] === v); }; const matchedSvcs = services.filter(s => selectorMatches(s.selector)); 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 k8sPorts = svcForm.ports.map(p => { const sp: Record = { name: p.name || 'http', port: Number(p.port) || 80, targetPort: /^\d+$/.test(String(p.targetPort)) ? Number(p.targetPort) : p.targetPort, protocol: p.protocol || 'TCP', }; if (svcForm.type === 'NodePort' && p.nodePort) sp.nodePort = Number(p.nodePort); return sp; }); const svcManifest: Record = { apiVersion: 'v1', kind: 'Service', metadata: { name, namespace, labels: { app: deploymentName } }, spec: { type: svcForm.type, selector: selectorLabels, ports: k8sPorts, }, }; const json = JSON.stringify(svcManifest, 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: Record = { 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' }); } }; // 删除资源 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 (

加载中...

); } return (
{/* Selector 提示 */}
选择器:
{Object.entries(selectorLabels).map(([k, v]) => ( {k}={v} ))} {Object.keys(selectorLabels).length === 0 && ( (无 selector) )}
{/* Services */}
Services {matchedSvcs.length} 个关联

仅显示 selector 匹配此 Deployment 的关联 Service

{matchedSvcs.length === 0 && showCreate !== 'service' ? (
当前命名空间暂无关联 Service
) : (
{matchedSvcs.map(svc => { const matched = selectorMatches(svc.selector); return (
{matched ? : } {svc.name} {svc.type} {matched && 关联} {svc.clusterIP}
{/* Ports */}
{svc.ports.map((p, i) => ( {p.name && {p.name}: } {p.port}→{p.targetPort}{p.nodePort ? `/NodePort:${p.nodePort}` : ''}/{p.protocol} ))}
{/* Selector */}
selector: {Object.entries(svc.selector).map(([k, v]) => ( {k}={v} ))}
); })}
)} {/* ══════════ 创建 Service 表单 — 多端口 ══════════ */} {showCreate === 'service' && (
创建新 Service
{/* 基本信息 */}
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" />
{/* 快速预设 */}
{PORT_PRESETS.map(p => ( ))}
{/* 端口列表 */}
{svcForm.ports.map((p, i) => (
{/* 名称 */} updatePort(i, 'name', e.target.value)} placeholder="http" className="h-7 w-[72px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" /> {/* 协议 */} {/* port → targetPort */} updatePort(i, 'port', e.target.value)} type="number" placeholder="port" className="h-7 w-[72px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" /> → updatePort(i, 'targetPort', e.target.value)} type="number" placeholder="targetPort" className="h-7 w-[72px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" /> {/* NodePort (仅 NodePort 类型) */} {svcForm.type === 'NodePort' && ( updatePort(i, 'nodePort', e.target.value)} type="number" placeholder="30000-32767" className="h-7 w-[80px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" /> )} {/* 删除 */}
))} {/* 添加自定义端口 */}
{/* Selector 预览 */}
selector (自动检测): {Object.keys(selectorLabels).length > 0 ? ( Object.entries(selectorLabels).map(([k, v]) => ( {k}={v} )) ) : ( 未检测到 )}
)}
{/* Ingresses */}
Ingresses (应用路由) {matchedIngs.length}/{ingresses.length}

backend 指向关联 Service 的 Ingress 自动高亮

{ingresses.length === 0 ? (
当前命名空间暂无 Ingress
) : (
{ingresses.map(ing => { const matched = matchedIngs.includes(ing); return (
{matched ? : } {ing.name} {ing.className} {matched && 关联}
{/* Rules */}
{ing.rules.map((r, i) => (
{r.host || '*'} {r.paths.map((p, j) => ( {p.path} → {(p as any).backend?.service}:{(p as any).backend?.port} ))}
))}
); })}
)} {/* 创建 Ingress 表单 */} {showCreate === 'ingress' && (
创建新 Ingress
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" />
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" />
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" />
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" />
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" />
)}
{/* YAML 弹窗 */} {yamlModal && (
{/* YamlViewer can be re-added here if needed */}
)} {/* 底部操作 */}
); }