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

395 lines
17 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 { 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<SvcSummary[]>([]);
const [ingresses, setIngresses] = useState<IngSummary[]>([]);
const [selectorLabels, setSelectorLabels] = useState<Record<string, string>>({});
const [visualEditor, setVisualEditor] = useState<{ resource: 'services' | 'ingresses'; name: string } | null>(null);
const [showCreate, setShowCreate] = useState<'service' | 'ingress' | null>(null);
// 创建 Service 表单 — 多端口
const [svcForm, setSvcForm] = useState<SvcFormState>({
name: '', type: 'ClusterIP',
ports: [{ name: 'http', port: '8080', targetPort: '8080', protocol: 'TCP', nodePort: '' }],
});
// 创建 Ingress 表单
const [ingForm, setIngForm] = useState<IngFormState>({
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 || '<none>',
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<string, any>[] = 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 || '<none>',
// 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<string, string>): 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<string, any> = {
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<string, any> = {
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<string, 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 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 (
<Card className="dg-card">
<CardContent className="p-12 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto dg-text-muted" />
<p className="dg-text-muted mt-3 text-sm"> {i18n.t('monitor.logPanel.logResults.loading')} </p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{/* Selector 提示 */}
<Card className="dg-card">
<CardContent className="p-3">
<div className="flex items-center gap-2 text-xs dg-text-secondary">
<Network className="h-3.5 w-3.5 dg-text-blue" />
<span> {i18n.t('kubernetes.deploymentEditService.selectorLabel')} </span>
<div className="flex flex-wrap gap-1">
{Object.entries(selectorLabels).map(([k, v]) => (
<code key={k} className="bg-[var(--dg-bg-tertiary)] px-1.5 py-0.5 rounded text-[var(--dg-text-secondary)] text-[11px]">{k}={v}</code>
))}
{Object.keys(selectorLabels).length === 0 && (
<span className="dg-text-muted italic">(无 selector)</span>
)}
</div>
<Button size="sm" variant="ghost" className="h-6 ml-auto text-xs dg-text-secondary" onClick={load}>
<RefreshCw className="h-3 w-3 mr-1" />{i18n.t("common.refresh")}
</Button>
</div>
</CardContent>
</Card>
{/* Services */}
<Card className="dg-card">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Network className="h-4 w-4 dg-text-blue" />
Services
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">{matchedSvcs.length} 个关联</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs"
onClick={() => {
setSvcForm({ name: `${deploymentName}-svc`, type: 'ClusterIP', ports: [{ name: 'http', port: '8080', targetPort: '8080', protocol: 'TCP', nodePort: '' }] });
setShowCreate('service');
}}>
<Plus className="h-3 w-3 mr-1" />{i18n.t("common.createService")}
</Button>
</div>
<p className="text-xs dg-text-muted mt-1"> {i18n.t('kubernetes.deploymentEditService.selectorHint')} </p>
</CardHeader>
<CardContent className="pt-0">
{matchedSvcs.length === 0 && showCreate !== 'service' ? (
<div className="text-center py-6 dg-text-muted text-sm border border-dashed border-[var(--dg-border)] rounded-lg">
<Network className="h-8 w-8 mx-auto mb-2 dg-text-muted" />
当前命名空间暂无关联 Service
</div>
) : (
<div className="space-y-2">
{matchedSvcs.map(svc => (
<ServiceListItem
key={svc.name}
svc={svc}
matched={selectorMatches(svc.selector)}
onEdit={() => setVisualEditor({ resource: 'services', name: svc.name })}
onDelete={() => deleteResource('service', svc.name)}
/>
))}
</div>
)}
{showCreate === 'service' && (
<ServiceCreateForm
svcForm={svcForm}
setSvcForm={setSvcForm}
selectorLabels={selectorLabels}
deploymentName={deploymentName}
onCancel={() => setShowCreate(null)}
onCreate={createService}
/>
)}
</CardContent>
</Card>
{/* Ingresses */}
<Card className="dg-card">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<GitBranch className="h-4 w-4 dg-text-purple" />
Ingresses (应用路由)
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">{matchedIngs.length}/{ingresses.length}</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-[var(--dg-border)] text-[var(--dg-text-secondary)] 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" />{i18n.t("common.createIngress")}
</Button>
</div>
<p className="text-xs dg-text-muted mt-1">backend 指向关联 Service 的 Ingress 自动高亮</p>
</CardHeader>
<CardContent className="pt-0">
{ingresses.length === 0 ? (
<div className="text-center py-6 dg-text-muted text-sm border border-dashed border-[var(--dg-border)] rounded-lg">
<GitBranch className="h-8 w-8 mx-auto mb-2 dg-text-muted" />
当前命名空间暂无 Ingress
</div>
) : (
<div className="space-y-2">
{ingresses.map(ing => (
<IngressListItem
key={ing.name}
ing={ing}
matched={matchedIngs.includes(ing)}
onEdit={() => setVisualEditor({ resource: 'ingresses', name: ing.name })}
onDelete={() => deleteResource('ingress', ing.name)}
/>
))}
</div>
)}
{showCreate === 'ingress' && (
<IngressCreateForm
ingForm={ingForm}
setIngForm={setIngForm}
deploymentName={deploymentName}
onCancel={() => setShowCreate(null)}
onCreate={createIngress}
/>
)}
</CardContent>
</Card>
{visualEditor && (
<ResourceVisualEditor
clusterCode={clusterCode}
namespace={namespace}
resource={visualEditor.resource}
name={visualEditor.name}
onClose={() => setVisualEditor(null)}
onSaved={() => { setVisualEditor(null); load(); }}
/>
)}
<div className="flex justify-end gap-2 pt-2">
<Button size="sm" variant="outline" className="h-7 text-xs border-[var(--dg-border)] dg-text-secondary" onClick={onCancel}> {i18n.t('kubernetes.deploymentEditService.backToView')} </Button>
</div>
</div>
);
}