// 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 { Badge } from '@/components/ui/badge'; import { Network, GitBranch, Loader2, RefreshCw, Plus, } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { useConfirm } from '@/components/ui/confirm-dialog'; import { kubernetesApi } from '@/api'; import { dumpManifest } from './k8s-manifest-utils'; import { ResourceVisualEditor } from './ResourceVisualEditor'; import i18n from '@/i18n'; import type { SvcSummary, IngSummary, SvcFormState, IngFormState } from './deployment-edit-service/types'; import { toArray } from './deployment-edit-service/types'; import { ServiceCreateForm } from './deployment-edit-service/ServiceCreateForm'; import { IngressCreateForm } from './deployment-edit-service/IngressCreateForm'; import { ServiceListItem } from './deployment-edit-service/ServiceListItem'; import { IngressListItem } from './deployment-edit-service/IngressListItem'; interface Props { clusterCode: string; namespace: string; deploymentName: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any manifest: any | null; // eslint-disable-next-line @typescript-eslint/no-explicit-any onSaved: (updated?: any) => void; onCancel: () => void; } export function DeploymentEditService({ clusterCode, namespace, deploymentName, manifest, onCancel }: Props) { const { toast } = useToast(); const confirm = useConfirm(); const [loading, setLoading] = useState(true); const [services, setServices] = useState([]); const [ingresses, setIngresses] = useState([]); const [selectorLabels, setSelectorLabels] = useState>({}); const [visualEditor, setVisualEditor] = useState<{ resource: 'services' | 'ingresses'; name: string } | null>(null); const [showCreate, setShowCreate] = useState<'service' | 'ingress' | null>(null); // 创建 Service 表单 — 多端口 const [svcForm, setSvcForm] = useState({ 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 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); // eslint-disable-next-line @typescript-eslint/no-explicit-any const svcSummaries: SvcSummary[] = allSvcs.map((s: any) => { const spec = s.spec || {}; const ports = Array.isArray(spec.ports) ? spec.ports.map(( // eslint-disable-next-line @typescript-eslint/no-explicit-any 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(( // eslint-disable-next-line @typescript-eslint/no-explicit-any 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, }; }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const ingSummaries: IngSummary[] = allIngs.map((i: any) => { const spec = i.spec || {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any const rawRules: Record[] = Array.isArray(spec.rules) ? spec.rules : (Array.isArray(i.rules) ? i.rules : []); // eslint-disable-next-line @typescript-eslint/no-explicit-any const rules = rawRules.map((r: any) => ({ host: r.host || '', // eslint-disable-next-line @typescript-eslint/no-explicit-any 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 || '', // eslint-disable-next-line @typescript-eslint/no-explicit-any hosts: rules.map((r: any) => r.host).filter(Boolean), rules, raw: i, }; }); setServices(svcSummaries); setIngresses(ingSummaries); } catch (e) { 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 as string))) ); // 创建 Service(多端口) const createService = async () => { const name = svcForm.name.trim() || `${deploymentName}-svc`; const k8sPorts = svcForm.ports.map(p => { // eslint-disable-next-line @typescript-eslint/no-explicit-any 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; }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const svcManifest: Record = { apiVersion: 'v1', kind: 'Service', metadata: { name, namespace, labels: { app: deploymentName } }, spec: { type: svcForm.type, selector: selectorLabels, ports: k8sPorts, }, }; try { const yaml = await dumpManifest(svcManifest); await kubernetesApi.applyYAML(clusterCode, yaml); toast({ title: i18n.t('k8s.editService.serviceCreated'), description: name }); setShowCreate(null); load(); } catch (e) { // eslint-disable-next-line @typescript-eslint/no-explicit-any toast({ title: i18n.t('monitor.dashboard.custom.toastCreateFailed'), description: (e as any)?.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; // eslint-disable-next-line @typescript-eslint/no-explicit-any const ingManifest: 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 ingManifest.spec.ingressClassName; try { const yaml = await dumpManifest(ingManifest); await kubernetesApi.applyYAML(clusterCode, yaml); toast({ title: i18n.t('k8s.editService.serviceCreated'), description: name }); setShowCreate(null); load(); } catch (e) { // eslint-disable-next-line @typescript-eslint/no-explicit-any toast({ title: i18n.t('monitor.dashboard.custom.toastCreateFailed'), description: (e as any)?.message || String(e), variant: 'destructive' }); } }; // 删除资源 const deleteResource = async (kind: 'service' | 'ingress', name: string) => { const ok = await confirm({ title: i18n.t('common.delete'), description: i18n.t('k8s.toast.confirmDeleteResource', { kind: kind === 'service' ? 'Service' : 'Ingress', name }), confirmText: i18n.t('kubernetes.storage.delete2'), variant: 'destructive', }); if (!ok) return; try { const resource = kind === 'service' ? 'services' : 'ingresses'; await kubernetesApi.deleteResourceYaml(clusterCode, namespace, resource, name); toast({ title: i18n.t('monitor.silenceRules.deleted'), description: i18n.t('k8s.toast.resourceDeleted', { kind: kind === 'service' ? 'Service' : 'Ingress', name }) }); load(); } catch (e) { // eslint-disable-next-line @typescript-eslint/no-explicit-any toast({ title: i18n.t('kubernetes.msg.deleteFailed2'), description: (e as any)?.message || String(e), variant: 'destructive' }); } }; if (loading) { return (

{i18n.t('monitor.logPanel.logResults.loading')}

); } return (
{/* Selector 提示 */}
{i18n.t('kubernetes.deploymentEditService.selectorLabel')}
{Object.entries(selectorLabels).map(([k, v]) => ( {k}={v} ))} {Object.keys(selectorLabels).length === 0 && ( (无 selector) )}
{/* Services */}
Services {matchedSvcs.length} 个关联

{i18n.t('kubernetes.deploymentEditService.selectorHint')}

{matchedSvcs.length === 0 && showCreate !== 'service' ? (
当前命名空间暂无关联 Service
) : (
{matchedSvcs.map(svc => ( setVisualEditor({ resource: 'services', name: svc.name })} onDelete={() => deleteResource('service', svc.name)} /> ))}
)} {showCreate === 'service' && ( setShowCreate(null)} onCreate={createService} /> )}
{/* Ingresses */}
Ingresses (应用路由) {matchedIngs.length}/{ingresses.length}

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

{ingresses.length === 0 ? (
当前命名空间暂无 Ingress
) : (
{ingresses.map(ing => ( setVisualEditor({ resource: 'ingresses', name: ing.name })} onDelete={() => deleteResource('ingress', ing.name)} /> ))}
)} {showCreate === 'ingress' && ( setShowCreate(null)} onCreate={createIngress} /> )}
{visualEditor && ( setVisualEditor(null)} onSaved={() => { setVisualEditor(null); load(); }} /> )}
); }