Files
aiops/frontend/src/components/kubernetes/resource-visual-editors/IngressEditor.tsx
T

216 lines
12 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.
/* eslint-disable @typescript-eslint/no-explicit-any */
// IngressEditor — Ingress Host/Path 路由规则可视化编辑器 (从 ResourceVisualEditor.tsx 拆分)
import { useState, useEffect } from 'react';
import { Plus, Trash2, Loader2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { kubernetesApi } from '@/api';
import i18n from '@/i18n';
export interface IngressEditorProps {
manifest: any;
clusterCode: string;
onSave: (m: any) => void;
saving: boolean;
isCreate?: boolean;
}
export function IngressEditor({ manifest, clusterCode, onSave, saving, isCreate }: IngressEditorProps) {
const spec = manifest?.spec || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [ingressClass, setIngressClass] = useState(spec.ingressClassName || 'nginx');
// IngressClass 列表(关联集群中的 IngressController)
const [ingressClasses, setIngressClasses] = useState<Array<{ name: string; controller: string }>>([]);
const [loadingClasses, setLoadingClasses] = useState(false);
// 加载集群 IngressClass 列表
useEffect(() => {
if (!clusterCode) return;
setLoadingClasses(true);
kubernetesApi.getIngressClasses(clusterCode).then((res: any) => {
const list = Array.isArray(res) ? res : (res?.list || res || []);
setIngressClasses(list);
// 新建时如果还没指定且有列表,尝试默认选第一个
if (isCreate && (!spec.ingressClassName || spec.ingressClassName === 'nginx') && list.length > 0) {
// 优先选 nginx 或第一个
const prefer = list.find((c: any) => c.name?.toLowerCase().includes('nginx')) || list[0];
if (prefer?.name) setIngressClass(prefer.name);
}
}).catch(() => {}).finally(() => setLoadingClasses(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clusterCode]);
const [rules, setRules] = useState<{ host: string; paths: { path: string; pathType: string; serviceName: string; servicePort: string }[] }[]>(
(spec.rules || []).map(( r: any) => ({
host: r.host || '',
paths: (r.http?.backend?.servicePort != null
? // Old-style single backend
[{ path: '/', pathType: 'Prefix', serviceName: r.http?.backend?.serviceName || '', servicePort: String(r.http?.backend?.servicePort || '80') }]
: (r.http?.paths || []).map(( p: any) => ({
path: p.path || '/',
pathType: p.pathType || 'Prefix',
serviceName: p.backend?.service?.name || p.backend?.serviceName || '',
servicePort: String(p.backend?.service?.port?.number ?? p.backend?.servicePort ?? '80'),
}))
),
}))
);
const updateRule = (ri: number, host: string) => {
const next = [...rules];
next[ri] = { ...next[ri], host };
setRules(next);
};
const addRule = () => setRules([...rules, { host: '', paths: [{ path: '/', pathType: 'Prefix', serviceName: '', servicePort: '80' }] }]);
const removeRule = (ri: number) => setRules(rules.filter((_, idx) => idx !== ri));
const updatePath = (ri: number, pi: number, field: string, val: string) => {
const next = [...rules];
next[ri].paths[pi] = { ...next[ri].paths[pi], [field]: val };
setRules(next);
};
const addPath = (ri: number) => {
const next = [...rules];
next[ri].paths.push({ path: '/', pathType: 'Prefix', serviceName: '', servicePort: '80' });
setRules(next);
};
const removePath = (ri: number, pi: number) => {
const next = [...rules];
next[ri].paths = next[ri].paths.filter((_, idx) => idx !== pi);
setRules(next);
};
const handleSave = () => {
const newRules = rules.map(r => ({
host: r.host,
http: {
paths: r.paths.map(p => ({
path: p.path,
pathType: p.pathType,
backend: { service: { name: p.serviceName, port: { number: parseInt(p.servicePort) || 80 } } },
})),
},
}));
const newSpec: Record<string, any> = { ...spec, rules: newRules };
if (ingressClass.trim()) newSpec.ingressClassName = ingressClass.trim();
else delete newSpec.ingressClassName;
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, spec: newSpec });
};
return (
<div className="flex flex-col h-full min-h-0">
<div className="shrink-0 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="min-w-0">
<Label className="text-[var(--dg-text-secondary)] text-xs"> {i18n.t('kubernetes.secret.secretName')} </Label>
<Input value={resName} onChange={(e) => setResName(e.target.value)} readOnly={!isCreate} className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</div>
<div className="min-w-0">
<Label className="text-[var(--dg-text-secondary)] text-xs flex items-center gap-1">
Ingress Class
<span className="text-[10px] text-[var(--dg-text-muted)]">(可选)</span>
</Label>
{/* IngressClass 下拉 + 手工输入支持 // Logan.Li */}
<div className="space-y-1 mt-1">
<Select
value={ingressClass || ''}
onValueChange={(val) => {
if (val && val !== '__manual__') {
setIngressClass(val);
}
}}
>
<SelectTrigger className="h-8 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={loadingClasses ? i18n.t('monitor.logPanel.logResults.loading') : i18n.t('common.loading')} />
</SelectTrigger>
<SelectContent>
{ingressClasses.length > 0 ? (
ingressClasses.map((ic) => (
<SelectItem key={ic.name} value={ic.name}>
{ic.name} {ic.controller ? <span className="text-[var(--dg-text-muted)] text-[10px]">({ic.controller})</span> : ''}
</SelectItem>
))
) : (
!loadingClasses && <div className="px-2 py-1.5 text-xs text-[var(--dg-text-muted)]"> {i18n.t('common.loading')} </div>
)}
{/* 当前自定义值作为选项,确保选中态 */}
{ingressClass && !ingressClasses.some((ic) => ic.name === ingressClass) && (
<SelectItem value={ingressClass}>{ingressClass} (手动输入)</SelectItem>
)}
<SelectItem value="__manual__">—— 继续手动输入 ——</SelectItem>
</SelectContent>
</Select>
{/* 总是显示输入框支持手工输入或修改 */}
<div className="text-[10px] text-[var(--dg-text-muted)] flex items-center gap-1">
手工输入:
<Input
value={ingressClass}
onChange={(e) => setIngressClass(e.target.value)}
placeholder="alb / nginx / my-ingress-class"
className="flex-1 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
{/* 帮助提示 */}
<div className="text-[10px] text-[var(--dg-text-muted)] leading-tight">
您的集群中可能有多个 IngressController,您可在此指定当前 Ingress 对应哪个 IngressController。<br />
从 IngressClass 列表选择,或手工输入名称。
</div>
{/* 常见示例 */}
{(ingressClasses.length === 0 || ingressClass) && (
<div className="text-[10px] text-[var(--dg-text-muted)]">
常见:<span className="font-mono">alb</span> → ingress.k8s.alibabacloud/alb &nbsp;&nbsp; <span className="font-mono">nginx</span> → k8s.io/ingress-nginx
</div>
)}
</div>
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col mt-3">
<div className="flex items-center justify-between mb-2 shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('kubernetes.ingress.rules')} </Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRule}><Plus className="h-3 w-3 mr-1" /> {i18n.t('k8s.service.portLabel')} </Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-3 border border-[var(--dg-border)] rounded-lg p-2 bg-[var(--dg-bg-secondary)]">
{rules.map((rule, ri) => (
<div key={ri} className="p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] space-y-2 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<Input
value={rule.host}
onChange={e => updateRule(ri, e.target.value)}
placeholder={i18n.t('k8s.rve.hostPlaceholder')}
className="flex-1 min-w-0 font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
title={rule.host}
/>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 dg-text-red hover:dg-text-red" onClick={() => removeRule(ri)}><Trash2 className="h-3 w-3" /></Button>
</div>
{rule.paths.map((p, pi) => (
<div key={pi} className="overflow-x-auto pl-4">
<div className="grid grid-cols-[minmax(80px,1fr)_100px_minmax(80px,1fr)_60px_28px] gap-1.5 items-center min-w-[420px]">
<Input value={p.path} onChange={e => updatePath(ri, pi, 'path', e.target.value)} placeholder={i18n.t('k8s.ingress.pathPlaceholder')} className="font-mono text-xs h-7 min-w-0 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Select value={p.pathType} onValueChange={v => updatePath(ri, pi, 'pathType', v)}>
<SelectTrigger className="h-7 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="Prefix">Prefix</SelectItem><SelectItem value="Exact">Exact</SelectItem></SelectContent>
</Select>
<Input value={p.serviceName} onChange={e => updatePath(ri, pi, 'serviceName', e.target.value)} placeholder="Service" className="font-mono text-xs h-7 min-w-0 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Input value={p.servicePort} onChange={e => updatePath(ri, pi, 'servicePort', e.target.value)} placeholder={i18n.t('system.ldapPort')} type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red" onClick={() => removePath(ri, pi)}><Trash2 className="h-3 w-3" /></Button>
</div>
</div>
))}
<Button variant="ghost" size="sm" className="h-6 text-xs text-[var(--dg-text-muted)]" onClick={() => addPath(ri)}><Plus className="h-3 w-3 mr-1" /> {i18n.t('kubernetes.ingress.addPath')} </Button>
</div>
))}
{rules.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs"> {i18n.t('common.name')} </div>}
</div>
</div>
<div className="shrink-0 flex justify-end gap-2 pt-3 mt-2 border-t border-[var(--dg-border)]">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
保存 Ingress
</Button>
</div>
</div>
);
}