Files
aiops/frontend/src/components/kubernetes/ResourceVisualEditor.tsx
T
attacker 72bfb0e12d fix(i18n): 回退到稳定的 32% 覆盖率状态 (tsc + Vite 构建通过)
批量转换脚本引入的语法错误已回退,保留稳定的 i18n 转换:
- 翻译词典: 22 namespaces, 3600+ keys
- 已接入: 145+ 文件 (Dashboard/Login/K8s 主页面/Monitor/System/Deploy)
- 语言切换器在 Header 中可用 (🇨🇳/🇺🇸)
- tsc 零错误, Vite 构建成功
2026-07-03 02:02:29 +08:00

880 lines
46 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 */
// ResourceVisualEditor — ConfigMap/Secret/Service/Ingress/PVC 可视化编辑 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import type { ComponentType } from 'react';
import { X, Save, Loader2, Plus, Trash2, FileText, Network, Shield, Database, ChevronRight} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ModalOverlay } from '@/components/ui/modal-overlay';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import { createEmptyManifest } from './resource-create-templates';
import { parseManifest, parseEditableManifest, prepareManifestForSave, prepareYamlForSave } from './k8s-manifest-utils';
import { YamlEditorToolbar } from './YamlEditorToolbar';
import { YamlCodeMirror } from './YamlCodeMirror';
import { getYamlResourceConfig, yamlDownloadFilename } from './k8s-yaml-resource-config';
import { downloadYamlFile } from '@/utils/export';
interface ResourceVisualEditorProps {
clusterCode: string;
namespace: string;
resource: string; // 'configmaps' | 'secrets' | 'services' | 'ingresses' | 'pvcs'
name: string;
onClose: () => void;
onSaved?: () => void;
/** 新建模式:不拉取远端 YAML,使用空模板 // Logan.Li */
isCreate?: boolean;
}
export function ResourceVisualEditor({ clusterCode, namespace, resource, name, onClose, onSaved, isCreate = false }: ResourceVisualEditorProps) {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [rawYaml, setRawYaml] = useState('');
const [manifest, setManifest] = useState<any>(null);
const [mode, setMode] = useState<'visual' | 'yaml'>('visual');
const [displayYaml, setDisplayYaml] = useState('');
const [editYaml, setEditYaml] = useState('');
const [yamlEditing, setYamlEditing] = useState(false);
const [showRawYaml, setShowRawYaml] = useState(false);
const [yamlCopied, setYamlCopied] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
try {
if (isCreate) {
const empty = createEmptyManifest(resource, namespace, name || '');
const yamlStr = await prepareManifestForSave(empty);
setManifest(empty);
setDisplayYaml(yamlStr);
setEditYaml(yamlStr);
setRawYaml('');
setYamlEditing(true);
return;
}
const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
setRawYaml(text);
// 先解析原始 YAML,再用对象级剥离;cleanYAML 会破坏 managedFields 导致解析失败 // Logan.Li
const { manifest: parsed, yaml: editableYaml } = await parseEditableManifest(text);
setDisplayYaml(editableYaml);
setEditYaml(editableYaml);
setYamlEditing(false);
setShowRawYaml(false);
setManifest(parsed);
if (!parsed) {
setMode('yaml');
toast({ title: '可视化编辑不可用', description: '无法解析资源结构,已切换到 YAML 编辑模式' });
}
} catch (e) {
toast({ title: '加载失败', description: e instanceof Error ? e.message : String(e), variant: 'destructive' });
} finally { setLoading(false); }
}, [clusterCode, namespace, resource, name, isCreate]);
useEffect(() => { loadData(); }, [loadData]);
const handleSave = async (updatedManifest: any) => {
setSaving(true);
try {
const yamlStr = await prepareManifestForSave(updatedManifest);
const resName = updatedManifest?.metadata?.name || name;
if (isCreate) {
if (!resName?.trim()) {
toast({ title: '请填写名称', variant: 'destructive' });
return;
}
await kubernetesApi.applyYAML(clusterCode, yamlStr);
toast({ title: '创建成功', description: `${resource}/${resName}` });
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
toast({ title: '保存成功', description: `${resource}/${name} 已更新` });
}
onSaved?.();
onClose();
} catch (e) {
toast({ title: isCreate ? '创建失败' : '保存失败', description: e instanceof Error ? e.message : String(e), variant: 'destructive' });
} finally { setSaving(false); }
};
const handleSaveYaml = async () => {
setSaving(true);
try {
const yamlStr = await prepareYamlForSave(editYaml);
if (isCreate) {
await kubernetesApi.applyYAML(clusterCode, yamlStr);
toast({ title: '创建成功', description: 'YAML 已应用' });
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
toast({ title: '保存成功', description: `${resource}/${name} 已更新` });
}
setYamlEditing(false);
setDisplayYaml(yamlStr);
setEditYaml(yamlStr);
if (!isCreate) setRawYaml(yamlStr);
onSaved?.();
onClose();
} catch (e) {
toast({ title: '保存失败', description: e instanceof Error ? e.message : String(e), variant: 'destructive' });
} finally { setSaving(false); }
};
// 可视化 ↔ YAML 双向同步 // Logan.Li
const switchToVisual = async () => {
const parsed = await parseManifest(editYaml);
if (!parsed) {
toast({ title: '无法解析 YAML', description: '请检查语法后继续用 YAML 模式', variant: 'destructive' });
return;
}
setManifest(parsed);
setMode('visual');
};
const switchToYaml = async () => {
if (manifest) {
const yamlStr = await prepareManifestForSave(manifest);
setDisplayYaml(yamlStr);
setEditYaml(yamlStr);
}
setYamlEditing(false);
setShowRawYaml(false);
setMode('yaml');
};
const currentYamlText = () => (yamlEditing ? editYaml : (showRawYaml ? rawYaml : displayYaml));
const copyYamlContent = async () => {
await navigator.clipboard.writeText(currentYamlText());
setYamlCopied(true);
setTimeout(() => setYamlCopied(false), 2000);
};
const downloadYamlContent = () => {
const fname = yamlDownloadFilename(resource, namespace, name || manifest?.metadata?.name || 'resource');
downloadYamlFile(fname, currentYamlText());
toast({ title: '已下载', description: fname });
};
const resCfg = getYamlResourceConfig(resource);
const resourceMeta: Record<string, { title: string; icon: ComponentType<{ className?: string }>; color: string }> = {
configmaps: { title: 'ConfigMap', icon: FileText, color: 'dg-text-blue' },
secrets: { title: 'Secret', icon: Shield, color: 'dg-text-yellow' },
services: { title: 'Service', icon: Network, color: 'dg-text-cyan' },
ingresses: { title: 'Ingress', icon: ChevronRight, color: 'dg-text-purple' },
pvcs: { title: 'PersistentVolumeClaim', icon: Database, color: 'dg-text-green' },
};
const meta = resourceMeta[resource] || resourceMeta.configmaps;
const MetaIcon = meta.icon;
return (
<ModalOverlay onClose={onClose}>
<div
className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl w-[90vw] max-w-3xl h-[85vh] flex flex-col shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header:长名称/域名截断,避免挤压模式切换按钮 // Logan.Li */}
<div className="flex items-center justify-between p-4 border-b border-[var(--dg-border)] gap-3 shrink-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<MetaIcon className={`h-5 w-5 shrink-0 ${meta.color}`} />
<div className="min-w-0">
<h2 className="text-lg font-bold text-[var(--dg-text-primary)] truncate">
{isCreate ? `新建 ${meta.title}` : `${meta.title} 编辑`}
</h2>
<p
className="text-xs text-[var(--dg-text-muted)] truncate"
title={isCreate ? `${namespace} · ${clusterCode}` : `${name} · ${namespace} · ${clusterCode}`}
>
{isCreate ? `${namespace} · ${clusterCode}` : `${name} · ${namespace} · ${clusterCode}`}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Mode switcher */}
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden">
<button
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'visual' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
onClick={() => { if (mode === 'yaml') switchToVisual(); else setMode('visual'); }}
disabled={!manifest && mode !== 'yaml'}
>可视化编辑</button>
<button
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'yaml' ? 'bg-blue-600 text-white' : 'bg-transparent text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
onClick={() => { if (mode === 'visual') switchToYaml(); else setMode('yaml'); }}
>YAML 编辑</button>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)]" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
</div>
{/* Body */}
<div className={`flex-1 min-h-0 p-4 ${mode === 'yaml' ? 'flex flex-col overflow-hidden' : 'flex flex-col overflow-hidden'}`}>
{loading ? (
<div className="flex items-center justify-center h-full py-20"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>
) : mode === 'yaml' ? (
<div className="flex flex-col flex-1 min-h-0 gap-3">
<div className="flex justify-end shrink-0">
<YamlEditorToolbar
editing={yamlEditing}
saving={saving}
copied={yamlCopied}
showRaw={showRawYaml}
compact
readOnly={resCfg.readOnly}
showRawToggle={!isCreate && !!rawYaml}
onCopy={copyYamlContent}
onDownload={downloadYamlContent}
onEdit={() => { setEditYaml(displayYaml); setYamlEditing(true); setShowRawYaml(false); }}
onCancel={async () => {
setYamlEditing(false);
if (isCreate) {
const empty = createEmptyManifest(resource, namespace, name || '');
const yamlStr = await prepareManifestForSave(empty);
setDisplayYaml(yamlStr);
setEditYaml(yamlStr);
return;
}
const { yaml: stripped } = await parseEditableManifest(rawYaml);
setDisplayYaml(stripped);
setEditYaml(stripped);
}}
onSave={handleSaveYaml}
onToggleRaw={() => setShowRawYaml((v) => !v)}
/>
</div>
<YamlCodeMirror
value={yamlEditing ? editYaml : (showRawYaml ? rawYaml : displayYaml)}
editing={yamlEditing}
fill
fontSize="12px"
onChange={(val) => yamlEditing && setEditYaml(val)}
/>
</div>
) : manifest ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{resource === 'configmaps' && <ConfigMapEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'secrets' && <SecretEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'services' && <ServiceEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'ingresses' && <IngressEditor manifest={manifest} clusterCode={clusterCode} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{resource === 'pvcs' && <PvcEditor manifest={manifest} onSave={handleSave} saving={saving} isCreate={isCreate} />}
{!['configmaps', 'secrets', 'services', 'ingresses', 'pvcs'].includes(resource) && (
<div className="flex-1 flex items-center justify-center text-center text-[var(--dg-text-muted)]">
<div>
<p>该资源类型暂不支持可视化编辑</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}>切换到 YAML 编辑</Button>
</div>
</div>
)}
</div>
) : (
<div className="text-center py-12 text-[var(--dg-text-muted)]">
<p>无法解析资源结构</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}>切换到 YAML 编辑</Button>
</div>
)}
</div>
</div>
</ModalOverlay>
);
}
// ══════════════════════════════════════════════
// ConfigMap 编辑器 — 键值对表格
// ══════════════════════════════════════════════
function ConfigMapEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const data = manifest?.data || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [entries, setEntries] = useState<{ key: string; value: string }[]>(
Object.entries(data).map(([key, value]) => ({ key, value: String(value) }))
);
const [expandAll, setExpandAll] = useState(false);
const update = (i: number, field: 'key' | 'value', val: string) => {
const next = [...entries];
next[i] = { ...next[i], [field]: val };
setEntries(next);
};
const addRow = () => setEntries([...entries, { key: '', value: '' }]);
const removeRow = (i: number) => setEntries(entries.filter((_, idx) => idx !== i));
const handleSave = () => {
const newData: Record<string, string> = {};
entries.forEach(e => {
if (e.key.trim()) newData[e.key.trim()] = e.value;
});
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, data: newData });
};
return (
<div className="flex flex-col h-full">
<div className="shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</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="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">配置数据 (键值对)</Label>
<div className="flex gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
{expandAll ? '折叠全部' : '展开全部'}
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-2 border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)] p-2">
{entries.map((entry, i) => (
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<Input
value={entry.key}
onChange={e => update(i, 'key', e.target.value)}
placeholder="键名 (如 LOG_LEVEL)"
className="flex-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
<Textarea
value={entry.value}
onChange={e => update(i, 'value', e.target.value)}
placeholder="值"
rows={expandAll || entry.value.length > 300 ? 10 : (entry.value.length > 100 ? 6 : 3)}
className="flex-[2] font-mono text-xs min-h-[70px] max-h-[420px] resize-y bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] leading-relaxed"
/>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-red hover:dg-text-red shrink-0" onClick={() => removeRow(i)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{entries.length === 0 && (
<div className="text-center py-8 text-[var(--dg-text-muted)] text-xs">暂无配置数据,点击「添加」创建</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" />}
保存配置
</Button>
</div>
</div>
);
}
// ══════════════════════════════════════════════
// Secret 编辑器 — 键值对表格 (Base64 编码/解码)
// ══════════════════════════════════════════════
function SecretEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const rawData = manifest?.data || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [expandAll, setExpandAll] = useState(false);
// Decode base64 values for editing
const [entries, setEntries] = useState<{ key: string; value: string; encoded: boolean }[]>(
Object.entries(rawData).map(([key, value]) => {
const v = String(value);
try {
// Try to decode — if it's valid base64
const decoded = atob(v);
return { key, value: decoded, encoded: true };
} catch {
return { key, value: v, encoded: false };
}
})
);
const update = (i: number, field: 'key' | 'value', val: string) => {
const next = [...entries];
next[i] = { ...next[i], [field]: val };
setEntries(next);
};
const addRow = () => setEntries([...entries, { key: '', value: '', encoded: true }]);
const removeRow = (i: number) => setEntries(entries.filter((_, idx) => idx !== i));
const handleSave = () => {
const newData: Record<string, string> = {};
entries.forEach(e => {
if (e.key.trim()) {
// Re-encode if originally base64
newData[e.key.trim()] = e.encoded ? btoa(e.value) : e.value;
}
});
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, data: newData });
};
return (
<div className="flex flex-col h-full">
<div className="shrink-0 grid grid-cols-2 gap-4">
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</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>
<Label className="text-[var(--dg-text-secondary)] text-xs">类型</Label>
<Input value={manifest?.type || 'Opaque'} readOnly className="mt-1 font-mono text-xs bg-[var(--dg-bg-tertiary)]" />
</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">密钥数据 (已自动解码 Base64)</Label>
<div className="flex gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setExpandAll(!expandAll)}>
{expandAll ? '折叠全部' : '展开全部'}
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRow}><Plus className="h-3 w-3 mr-1" />添加</Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-2 border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)] p-2">
{entries.map((entry, i) => (
<div key={i} className="flex items-start gap-2 p-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<Input
value={entry.key}
onChange={e => update(i, 'key', e.target.value)}
placeholder="键名 (如 password)"
className="flex-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
<Textarea
value={entry.value}
onChange={e => update(i, 'value', e.target.value)}
placeholder="明文值"
rows={expandAll || entry.value.length > 300 ? 10 : (entry.value.length > 100 ? 6 : 3)}
className="flex-[2] font-mono text-xs min-h-[70px] max-h-[420px] resize-y bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] leading-relaxed"
/>
<Button variant="ghost" size="icon" className="h-8 w-8 dg-text-red hover:dg-text-red shrink-0" onClick={() => removeRow(i)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{entries.length === 0 && (
<div className="text-center py-8 text-[var(--dg-text-muted)] text-xs">暂无密钥数据,点击「添加」创建</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" />}
保存密钥
</Button>
</div>
</div>
);
}
// ══════════════════════════════════════════════
// Service 编辑器 — 端口配置 + 类型 + 选择器
// ══════════════════════════════════════════════
function ServiceEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const spec = manifest?.spec || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [svcType, setSvcType] = useState(spec.type || 'ClusterIP');
const [ports, setPorts] = useState<any[]>(
(spec.ports || []).map(( p: any) => ({
name: p.name || '',
port: String(p.port || ''),
targetPort: String(p.targetPort || ''),
protocol: p.protocol || 'TCP',
nodePort: p.nodePort ? String(p.nodePort) : '',
}))
);
const [selector, setSelector] = useState<{ key: string; value: string }[]>(
Object.entries(spec.selector || {}).map(([key, value]) => ({ key, value: String(value) }))
);
const updatePort = (i: number, field: string, val: string) => {
const next = [...ports];
next[i] = { ...next[i], [field]: val };
setPorts(next);
};
const addPort = () => setPorts([...ports, { name: '', port: '', targetPort: '', protocol: 'TCP', nodePort: '' }]);
const removePort = (i: number) => setPorts(ports.filter((_, idx) => idx !== i));
const updateSelector = (i: number, field: 'key' | 'value', val: string) => {
const next = [...selector];
next[i] = { ...next[i], [field]: val };
setSelector(next);
};
const addSelector = () => setSelector([...selector, { key: '', value: '' }]);
const removeSelector = (i: number) => setSelector(selector.filter((_, idx) => idx !== i));
const handleSave = () => {
const newSpec: Record<string, any> = { ...spec };
newSpec.type = svcType;
newSpec.ports = ports.map(p => {
const port: Record<string, any> = {
port: parseInt(p.port) || 80,
targetPort: parseInt(p.targetPort) || parseInt(p.port) || 80,
protocol: p.protocol,
};
if (p.name) port.name = p.name;
if (p.nodePort && svcType === 'NodePort') port.nodePort = parseInt(p.nodePort);
return port;
});
const selObj: Record<string, string> = {};
selector.forEach(s => { if (s.key.trim()) selObj[s.key.trim()] = s.value; });
newSpec.selector = selObj;
onSave({ ...manifest, metadata: { ...manifest.metadata, name: resName.trim() }, spec: newSpec });
};
const svcTypes = [
{ value: 'ClusterIP', label: 'ClusterIP' },
{ value: 'NodePort', label: 'NodePort' },
{ value: 'LoadBalancer', label: 'LoadBalancer' },
];
return (
<div className="flex flex-col h-full">
<div className="shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-xs">服务名称</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="shrink-0 mt-3">
<Label className="text-[var(--dg-text-secondary)] text-xs mb-2 block">服务类型</Label>
<div className="flex rounded-lg border border-[var(--dg-border)] overflow-hidden w-fit">
{svcTypes.map((t) => (
<button
key={t.value}
type="button"
className={`px-3 py-1.5 text-xs transition-colors ${svcType === t.value ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-tertiary)]'}`}
onClick={() => setSvcType(t.value)}
>
{t.label}
</button>
))}
</div>
</div>
{/* 端口 + 选择器:统一滚动,避免条目过多时底部被裁切 // Logan.Li */}
<div className="flex-1 min-h-0 overflow-y-auto mt-3 pr-1 space-y-3">
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">端口映射</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addPort}><Plus className="h-3 w-3 mr-1" />添加端口</Button>
</div>
<div className="space-y-2 border border-[var(--dg-border)] rounded-lg p-2 bg-[var(--dg-bg-secondary)]">
{ports.map((p, i) => (
<div key={i} className="overflow-x-auto">
<div className="grid grid-cols-[minmax(80px,1fr)_80px_80px_80px_80px_32px] gap-2 items-center min-w-[480px]">
<Input value={p.name} onChange={e => updatePort(i, 'name', e.target.value)} placeholder="名称" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Input value={p.port} onChange={e => updatePort(i, 'port', e.target.value)} placeholder="端口" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Input value={p.targetPort} onChange={e => updatePort(i, 'targetPort', e.target.value)} placeholder="目标" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
<Select value={p.protocol} onValueChange={v => updatePort(i, 'protocol', 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="TCP">TCP</SelectItem><SelectItem value="UDP">UDP</SelectItem></SelectContent>
</Select>
{svcType === 'NodePort' ? (
<Input value={p.nodePort} onChange={e => updatePort(i, 'nodePort', e.target.value)} placeholder="NodePort" type="number" className="font-mono text-xs h-7 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
) : <div />}
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red" onClick={() => removePort(i)}><Trash2 className="h-3 w-3" /></Button>
</div>
</div>
))}
{ports.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无端口,点击「添加端口」</div>}
</div>
</div>
{/* Selector */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[var(--dg-text-secondary)] text-sm">选择器 (Selector)</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addSelector}><Plus className="h-3 w-3 mr-1" />添加标签</Button>
</div>
<div className="space-y-2">
{selector.map((s, i) => (
<div key={i} className="flex items-center gap-2 min-w-0">
<Input value={s.key} onChange={e => updateSelector(i, 'key', e.target.value)} placeholder="键 (如 app)" 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)]" />
<span className="text-[var(--dg-text-muted)] text-xs shrink-0">=</span>
<Input value={s.value} onChange={e => updateSelector(i, 'value', e.target.value)} placeholder="值 (如 nginx)" 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)]" />
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 dg-text-red hover:dg-text-red" onClick={() => removeSelector(i)}><Trash2 className="h-3 w-3" /></Button>
</div>
))}
{selector.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无选择器</div>}
</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" />}
保存 Service
</Button>
</div>
</div>
);
}
// ══════════════════════════════════════════════
// Ingress 编辑器 — Host/Path 路由规则
// ══════════════════════════════════════════════
function IngressEditor({ manifest, clusterCode, onSave, saving, isCreate }: { manifest: any; clusterCode: string; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
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));
}, [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">名称</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 ? '加载中...' : '选择或输入 IngressClass'} />
</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)]">未发现 IngressClass,请手动输入</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">路由规则</Label>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addRule}><Plus className="h-3 w-3 mr-1" />添加 Host</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="域名 (如 api.example.com)"
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="路径 /" 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="端口" 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" />添加路径</Button>
</div>
))}
{rules.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无路由规则,点击「添加 Host」</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>
);
}
// ══════════════════════════════════════════════
// PVC 编辑器 — 存储大小/访问模式/StorageClass
// ══════════════════════════════════════════════
function PvcEditor({ manifest, onSave, saving, isCreate }: { manifest: any; onSave: (m: any) => void; saving: boolean; isCreate?: boolean }) {
const spec = manifest?.spec || {};
const resources = spec.resources?.requests || {};
const [resName, setResName] = useState(manifest?.metadata?.name || '');
const [size, setSize] = useState(resources.storage || '1Gi');
const [accessMode, setAccessMode] = useState(spec.accessModes?.[0] || 'ReadWriteOnce');
const [storageClass, setStorageClass] = useState(spec.storageClassName || '');
const handleSave = () => {
onSave({
...manifest,
metadata: { ...manifest.metadata, name: resName.trim() },
spec: {
...spec,
accessModes: [accessMode],
storageClassName: storageClass || undefined,
resources: { requests: { storage: size } },
},
});
};
return (
<div className="flex flex-col h-full">
<div className="shrink-0">
<Label className="text-[var(--dg-text-secondary)] text-xs">名称</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="grid grid-cols-2 gap-4 mt-3 shrink-0">
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">存储大小</Label>
<Input
value={size}
onChange={e => setSize(e.target.value)}
placeholder="如 10Gi"
className="mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">单位:Gi (GiB), Mi (MiB), Ti (TiB)</p>
</div>
<div>
<Label className="text-[var(--dg-text-secondary)] text-xs">访问模式</Label>
<Select value={accessMode} onValueChange={setAccessMode}>
<SelectTrigger className="mt-1 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ReadWriteOnce">ReadWriteOnce (单节点读写)</SelectItem>
<SelectItem value="ReadOnlyMany">ReadOnlyMany (多节点只读)</SelectItem>
<SelectItem value="ReadWriteMany">ReadWriteMany (多节点读写)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="shrink-0 mt-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">StorageClass</Label>
<Input
value={storageClass}
onChange={e => setStorageClass(e.target.value)}
placeholder="留空使用默认 StorageClass"
className="mt-1 font-mono text-xs h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div className="flex-1" />
<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" />}
保存 PVC
</Button>
</div>
</div>
);
}