// 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, 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; 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([]); 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: '', 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 || '', 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 || '', 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): 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 (

加载中...

); } return (
{/* Selector 提示 */}
Deployment 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 ? (
当前命名空间暂无 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.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" />
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" />
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" />
{(svcForm.type === 'NodePort' || svcForm.type === 'LoadBalancer') && (
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" />
)}
)}
{/* 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.backend.service}:{p.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" />

后端将指向此 Deployment 关联的第一个 Service: {matchedSvcs[0]?.name || `${deploymentName}-svc`}

)}
{/* 底部说明 */}

Service / Ingress 是独立的 K8s 资源,保存即创建,不会修改 Deployment 本身。

编辑 Deployment manifest 请使用「基本信息」「容器信息」「存储挂载」「高级设置」Tab。

{/* YAML 编辑弹窗 */} {yamlModal && ( setYamlModal(null)} onSaved={load} /> )}
); }