216 lines
12 KiB
TypeScript
216 lines
12 KiB
TypeScript
/* 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 <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>
|
||
);
|
||
}
|