Files
aiops/frontend/src/components/kubernetes/DeploymentEditService.tsx
T
Logan.Li f45e203e21 refactor(frontend): P1-P2 架构统一 — utils/hooks/api 模块化
P1:
- utils/storage.ts、theme.ts、validate.ts + index 统一出口
- request/store/useTheme 接入 storage;清零 confirm/alert
- PipelineManager、TerminalAuditTab 拆分子模块(<700行红线)

P2:
- api/monitor、api/deploy 子目录化
- hooks/api 扩展:servers/pipelines/alerts/audit/sessionCommands
- PipelineManager、AuditOperationsTab 接入 TanStack Query
2026-06-26 00:18:14 +08:00

620 lines
29 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Network,
GitBranch,
Loader2,
RefreshCw,
ExternalLink,
Plus,
CheckCircle,
Server,
Trash2,
Minus,
Zap,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { kubernetesApi } from '@/api';
interface Props {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onCancel: () => void;
}
interface SvcSummary {
name: string;
type: string;
clusterIP: string;
externalIP: string;
ports: Array<{ port: number; targetPort: any; protocol: string; nodePort?: number; name?: string }>;
selector: Record<string, string>;
age: string;
raw: any;
}
interface IngSummary {
name: string;
className: string;
hosts: string[];
rules: Array<{ host: string; paths: Array<{ path: string; backend: { service: string; port: unknown} }> }>;
raw: any;
}
// 3 种常用端口预设
const PORT_PRESETS = [
{ label: 'HTTP', protocol: 'TCP' as const, port: 8080, targetPort: 8080, name: 'http' },
{ label: 'WebSocket', protocol: 'TCP' as const, port: 8001, targetPort: 8002, name: 'ws' },
{ label: 'gRPC', protocol: 'TCP' as const, port: 8002, targetPort: 8002, name: 'grpc' },
];
interface _SvcPortForm {
name: string;
port: string;
targetPort: string;
protocol: 'TCP' | 'UDP';
nodePort: string;
}
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 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 [yamlModal, setYamlModal] = useState<{ kind: 'service' | 'ingress'; name: string } | null>(null);
const [showCreate, setShowCreate] = useState<'service' | 'ingress' | null>(null);
// 创建 Service 表单 — 多端口
const [svcForm, setSvcForm] = useState<{
name: string; type: string;
ports: Array<{ name: string; port: string; targetPort: string; protocol: 'TCP' | 'UDP'; nodePort: string }>;
}>({
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 addPort = (preset?: typeof PORT_PRESETS[number]) => {
setSvcForm(s => ({
...s,
ports: [...s.ports, preset
? { name: preset.name, port: String(preset.port), targetPort: String(preset.targetPort), protocol: preset.protocol as 'TCP' | 'UDP', nodePort: '' }
: { name: '', port: '', targetPort: '', protocol: 'TCP' as const, nodePort: '' }
],
}));
};
const updatePort = (i: number, field: string, value: string) => {
setSvcForm(s => ({
...s,
ports: s.ports.map((p, idx) => idx === i ? { ...p, [field]: value } : p),
}));
};
const removePort = (i: number) => {
if (svcForm.ports.length <= 1) return;
setSvcForm(s => ({ ...s, ports: s.ports.filter((_, idx) => idx !== i) }));
};
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);
const svcSummaries: SvcSummary[] = allSvcs.map(( s: any) => {
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 || '<none>',
externalIP: Array.isArray(spec.externalIPs) ? spec.externalIPs.join(',') : '',
ports,
selector: spec.selector || s.selector || {},
age: s.age || s.created_at || '',
raw: s,
};
});
const ingSummaries: IngSummary[] = allIngs.map(( i: any) => {
const spec = i.spec || {};
const rawRules: Record<string, 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: {
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>',
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]);
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)))
);
// 创建 Service(多端口)
const createService = async () => {
const name = svcForm.name.trim() || `${deploymentName}-svc`;
const k8sPorts = svcForm.ports.map(p => {
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;
});
const svcManifest: Record<string, any> = {
apiVersion: 'v1',
kind: 'Service',
metadata: { name, namespace, labels: { app: deploymentName } },
spec: {
type: svcForm.type,
selector: selectorLabels,
ports: k8sPorts,
},
};
const json = JSON.stringify(svcManifest, 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: 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 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' });
}
};
// 删除资源
const deleteResource = async (kind: 'service' | 'ingress', name: string) => {
const ok = await confirm({
title: '删除资源',
description: `确认删除 ${kind === 'service' ? 'Service' : 'Ingress'} 「${name}」?此操作不可撤销。`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) 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 (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-12 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto text-slate-500" />
<p className="text-slate-500 mt-3 text-sm">加载中...</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{/* Selector 提示 */}
<Card className="bg-slate-900/50 border-slate-800">
<CardContent className="p-3">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Network className="h-3.5 w-3.5 text-blue-400" />
<span>选择器:</span>
<div className="flex flex-wrap gap-1">
{Object.entries(selectorLabels).map(([k, v]) => (
<code key={k} className="bg-slate-800 px-1.5 py-0.5 rounded text-slate-300 text-[11px]">{k}={v}</code>
))}
{Object.keys(selectorLabels).length === 0 && (
<span className="text-slate-600 italic">(无 selector)</span>
)}
</div>
<Button size="sm" variant="ghost" className="h-6 ml-auto text-xs text-slate-400" onClick={load}>
<RefreshCw className="h-3 w-3 mr-1" />刷新
</Button>
</div>
</CardContent>
</Card>
{/* Services */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Network className="h-4 w-4 text-blue-400" />
Services
<Badge variant="outline" className="border-slate-700 text-slate-400">{matchedSvcs.length} 个关联</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 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" />创建 Service
</Button>
</div>
<p className="text-xs text-slate-500 mt-1">仅显示 selector 匹配此 Deployment 的关联 Service</p>
</CardHeader>
<CardContent className="pt-0">
{matchedSvcs.length === 0 && showCreate !== 'service' ? (
<div className="text-center py-6 text-slate-500 text-sm border border-dashed border-slate-800 rounded-lg">
<Network className="h-8 w-8 mx-auto mb-2 text-slate-700" />
当前命名空间暂无关联 Service
</div>
) : (
<div className="space-y-2">
{matchedSvcs.map(svc => {
const matched = selectorMatches(svc.selector);
return (
<div key={svc.name} className={`rounded-lg border p-3 ${matched ? 'border-blue-700/50 bg-blue-950/20' : 'border-slate-800 bg-slate-950/30'}`}>
<div className="flex items-center gap-2 flex-wrap">
{matched ? <CheckCircle className="h-4 w-4 text-blue-400" /> : <Network className="h-4 w-4 text-slate-500" />}
<span className="text-sm font-semibold text-slate-200">{svc.name}</span>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{svc.type}</Badge>
{matched && <Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-[10px]">关联</Badge>}
<code className="text-[11px] text-slate-500 ml-auto">{svc.clusterIP}</code>
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400"
onClick={() => setYamlModal({ kind: 'service', name: svc.name })}>
<ExternalLink className="h-3 w-3 mr-1" />YAML
</Button>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
title="删除 Service" onClick={() => deleteResource('service', svc.name)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* Ports */}
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
{svc.ports.map((p, i) => (
<code key={i} className="bg-slate-800/60 px-1.5 py-0.5 rounded text-slate-300">
{p.name && <span className="text-slate-500">{p.name}: </span>}
{p.port}→{p.targetPort}{p.nodePort ? `/NodePort:${p.nodePort}` : ''}/{p.protocol}
</code>
))}
</div>
{/* Selector */}
<div className="mt-1 flex flex-wrap gap-1 text-[10px]">
<span className="text-slate-600">selector:</span>
{Object.entries(svc.selector).map(([k, v]) => (
<code key={k} className="text-slate-500">{k}={v}</code>
))}
</div>
</div>
);
})}
</div>
)}
{/* ══════════ 创建 Service 表单 — 多端口 ══════════ */}
{showCreate === 'service' && (
<div className="mt-3 border border-blue-800/50 rounded-lg p-3 bg-blue-950/20 space-y-3">
<div className="text-xs font-semibold text-blue-300 flex items-center gap-1.5">
<Plus className="h-3 w-3" />创建新 Service
</div>
{/* 基本信息 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-slate-500 text-[11px]">名称</Label>
<Input value={svcForm.name} onChange={e => 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" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">类型</Label>
<select value={svcForm.type} onChange={e => setSvcForm(s => ({ ...s, type: e.target.value }))}
className="h-7 mt-0.5 w-full bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2">
<option value="ClusterIP">ClusterIP</option>
<option value="NodePort">NodePort</option>
</select>
</div>
</div>
{/* 快速预设 */}
<div>
<Label className="text-slate-500 text-[11px] mb-1 block">快速添加常用端口</Label>
<div className="flex gap-1.5">
{PORT_PRESETS.map(p => (
<button key={p.name} onClick={() => addPort(p)}
className="flex items-center gap-1 px-2 py-1 rounded text-[10px] font-medium
bg-slate-800 border border-slate-700 text-slate-300 hover:border-blue-500/50 hover:text-blue-300 transition-colors">
<Zap className="h-2.5 w-2.5" />
{p.label}
<span className="text-slate-500 text-[9px]">{p.protocol} {p.port}:{p.targetPort}</span>
</button>
))}
</div>
</div>
{/* 端口列表 */}
<div className="space-y-2">
<Label className="text-slate-500 text-[11px]">
端口配置 <span className="text-slate-600">({svcForm.ports.length} 个端口)</span>
</Label>
{svcForm.ports.map((p, i) => (
<div key={i} className="flex items-center gap-1.5 p-2 rounded bg-slate-800/50 border border-slate-700/50">
{/* 名称 */}
<Input value={p.name} onChange={e => updatePort(i, 'name', e.target.value)}
placeholder="http"
className="h-7 w-[72px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
{/* 协议 */}
<select value={p.protocol} onChange={e => updatePort(i, 'protocol', e.target.value)}
className="h-7 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-1.5 w-16">
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
</select>
{/* port → targetPort */}
<Input value={p.port} onChange={e => updatePort(i, 'port', e.target.value)}
type="number" placeholder="port"
className="h-7 w-[72px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
<span className="text-slate-600 text-[10px]">→</span>
<Input value={p.targetPort} onChange={e => updatePort(i, 'targetPort', e.target.value)}
type="number" placeholder="targetPort"
className="h-7 w-[72px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
{/* NodePort (仅 NodePort 类型) */}
{svcForm.type === 'NodePort' && (
<Input value={p.nodePort} onChange={e => updatePort(i, 'nodePort', e.target.value)}
type="number" placeholder="30000-32767"
className="h-7 w-[80px] bg-slate-800 border-slate-700 text-slate-300 text-xs font-mono" />
)}
{/* 删除 */}
<Button size="icon" variant="ghost" className="h-6 w-6 text-slate-500 hover:text-red-400"
onClick={() => removePort(i)} disabled={svcForm.ports.length <= 1}>
<Minus className="h-3 w-3" />
</Button>
</div>
))}
{/* 添加自定义端口 */}
<Button size="sm" variant="outline" className="h-7 w-full border-dashed border-slate-700 text-slate-400 text-xs"
onClick={() => addPort()}>
<Plus className="h-3 w-3 mr-1" />添加自定义端口
</Button>
</div>
{/* Selector 预览 */}
<div className="text-[10px] text-slate-500 border-t border-slate-700/50 pt-2">
<span className="text-slate-600">selector (自动检测): </span>
{Object.keys(selectorLabels).length > 0 ? (
Object.entries(selectorLabels).map(([k, v]) => (
<code key={k} className="bg-slate-800 px-1 py-0.5 rounded text-slate-400 ml-1">{k}={v}</code>
))
) : (
<span className="text-slate-600 italic">未检测到</span>
)}
</div>
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="ghost" className="h-7 text-xs text-slate-400" onClick={() => setShowCreate(null)}>取消</Button>
<Button size="sm" className="h-7 bg-blue-600 hover:bg-blue-700 text-xs" onClick={createService}>创建</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Ingresses */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<GitBranch className="h-4 w-4 text-purple-400" />
Ingresses (应用路由)
<Badge variant="outline" className="border-slate-700 text-slate-400">{matchedIngs.length}/{ingresses.length}</Badge>
</CardTitle>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 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" />创建 Ingress
</Button>
</div>
<p className="text-xs text-slate-500 mt-1">backend 指向关联 Service 的 Ingress 自动高亮</p>
</CardHeader>
<CardContent className="pt-0">
{ingresses.length === 0 ? (
<div className="text-center py-6 text-slate-500 text-sm border border-dashed border-slate-800 rounded-lg">
<GitBranch className="h-8 w-8 mx-auto mb-2 text-slate-700" />
当前命名空间暂无 Ingress
</div>
) : (
<div className="space-y-2">
{ingresses.map(ing => {
const matched = matchedIngs.includes(ing);
return (
<div key={ing.name} className={`rounded-lg border p-3 ${matched ? 'border-purple-700/50 bg-purple-950/20' : 'border-slate-800 bg-slate-950/30'}`}>
<div className="flex items-center gap-2 flex-wrap">
{matched ? <CheckCircle className="h-4 w-4 text-purple-400" /> : <GitBranch className="h-4 w-4 text-slate-500" />}
<span className="text-sm font-semibold text-slate-200">{ing.name}</span>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{ing.className}</Badge>
{matched && <Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-[10px]">关联</Badge>}
<Button size="sm" variant="ghost" className="h-6 text-xs text-slate-400 ml-auto"
onClick={() => setYamlModal({ kind: 'ingress', name: ing.name })}>
<ExternalLink className="h-3 w-3 mr-1" />YAML
</Button>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
title="删除 Ingress" onClick={() => deleteResource('ingress', ing.name)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* Rules */}
<div className="mt-2 space-y-1 text-[11px]">
{ing.rules.map((r, i) => (
<div key={i} className="flex items-start gap-1.5">
<Server className="h-3 w-3 text-slate-500 mt-0.5" />
<code className="text-slate-300">{r.host || '*'}</code>
{r.paths.map((p, j) => (
<code key={j} className="bg-slate-800/60 px-1.5 py-0.5 rounded text-slate-400">
{p.path} → {(p as any).backend?.service}:{(p as any).backend?.port}
</code>
))}
</div>
))}
</div>
</div>
);
})}
</div>
)}
{/* 创建 Ingress 表单 */}
{showCreate === 'ingress' && (
<div className="mt-3 border border-purple-800/50 rounded-lg p-3 bg-purple-950/20 space-y-2">
<div className="text-xs font-semibold text-purple-300 flex items-center gap-1.5">
<Plus className="h-3 w-3" />创建新 Ingress
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-slate-500 text-[11px]">名称</Label>
<Input value={ingForm.name} onChange={e => 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" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">ingressClassName</Label>
<Input value={ingForm.className} onChange={e => 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" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">host (域名)</Label>
<Input value={ingForm.host} onChange={e => 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" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">path</Label>
<Input value={ingForm.path} onChange={e => 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" />
</div>
<div>
<Label className="text-slate-500 text-[11px]">Service 端口</Label>
<Input type="number" value={ingForm.svcPort} onChange={e => 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" />
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="ghost" className="h-7 text-xs text-slate-400" onClick={() => setShowCreate(null)}>取消</Button>
<Button size="sm" className="h-7 bg-purple-600 hover:bg-purple-700 text-xs" onClick={createIngress}>创建</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* YAML 弹窗 */}
{yamlModal && (
<div className="hidden">{/* YamlViewer can be re-added here if needed */}</div>
)}
{/* 底部操作 */}
<div className="flex justify-end gap-2 pt-2">
<Button size="sm" variant="ghost" className="h-7 text-xs text-slate-400" onClick={onCancel}>完成</Button>
<Button size="sm" className="h-7 bg-slate-600 text-xs" onClick={onSaved}>保存并关闭</Button>
</div>
</div>
);
}