将 ResourceVisualEditor.tsx (882L, 项目最大前端组件) 中的 5 个内联资源编辑器 提取为 resource-visual-editors/ 子目录下的独立文件: - ConfigMapEditor.tsx (95L) — ConfigMap 键值对表格 - SecretEditor.tsx (114L) — Secret Base64 编解码 - ServiceEditor.tsx (155L) — Service 端口/类型/选择器 - IngressEditor.tsx (215L) — Ingress Host/Path 路由规则 - PvcEditor.tsx (90L) — PVC 存储配置 主文件仅保留加载/保存逻辑 + YAML/可视化切换 + 资源类型分发。 清理 11 个 unused import。零逻辑变更,纯机械提取。 验证: tsc -b=0, npm build✓ (16.11s), ESLint errors=0, 17/17 测试通过
283 lines
14 KiB
TypeScript
283 lines
14 KiB
TypeScript
/* 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, Loader2, FileText, Network, Shield, Database, ChevronRight} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ModalOverlay } from '@/components/ui/modal-overlay';
|
|
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';
|
|
import i18n from '@/i18n';
|
|
import { ConfigMapEditor } from './resource-visual-editors/ConfigMapEditor';
|
|
import { SecretEditor } from './resource-visual-editors/SecretEditor';
|
|
import { ServiceEditor } from './resource-visual-editors/ServiceEditor';
|
|
import { IngressEditor } from './resource-visual-editors/IngressEditor';
|
|
import { PvcEditor } from './resource-visual-editors/PvcEditor';
|
|
|
|
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: i18n.t('kubernetes.resourcevisuale.s80ca4f8e'), description: i18n.t('kubernetes.resourcevisuale.scc94aa41') });
|
|
}
|
|
} catch (e) {
|
|
toast({ title: i18n.t('kubernetes.msg.loadFailed'), description: e instanceof Error ? e.message : String(e), variant: 'destructive' });
|
|
} finally { setLoading(false); }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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: i18n.t('deploy.form.nameRequired'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
await kubernetesApi.applyYAML(clusterCode, yamlStr);
|
|
toast({ title: i18n.t('monitor.dashboard.custom.toastCreateSuccess'), description: `${resource}/${resName}` });
|
|
} else {
|
|
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
|
|
toast({ title: i18n.t('kubernetes.msg.saveSuccess'), description: `${resource}/${name} 已更新` });
|
|
}
|
|
onSaved?.();
|
|
onClose();
|
|
} catch (e) {
|
|
toast({ title: isCreate ? i18n.t('monitor.dashboard.custom.toastCreateFailed') : i18n.t('kubernetes.msg.saveFailed'), 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: i18n.t('monitor.dashboard.custom.toastCreateSuccess'), description: i18n.t('kubernetes.resourcevisuale.se5ff4ba7') });
|
|
} else {
|
|
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
|
|
toast({ title: i18n.t('kubernetes.msg.saveSuccess'), description: `${resource}/${name} 已更新` });
|
|
}
|
|
setYamlEditing(false);
|
|
setDisplayYaml(yamlStr);
|
|
setEditYaml(yamlStr);
|
|
if (!isCreate) setRawYaml(yamlStr);
|
|
onSaved?.();
|
|
onClose();
|
|
} catch (e) {
|
|
toast({ title: i18n.t('kubernetes.msg.saveFailed'), 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: i18n.t('kubernetes.resourcevisuale.sf53bdf65'), description: i18n.t('kubernetes.resourcevisuale.sf2219a47'), 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: i18n.t('kubernetes.yamlviewertsx.s9719fec8'), 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'}
|
|
> {i18n.t('k8s.resource.visualEditor')} </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> {i18n.t('kubernetes.resourcevisuale.sfeb34f55')} </p>
|
|
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}> {i18n.t('kubernetes.resourcevisuale.s54c78d4f')} </Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12 text-[var(--dg-text-muted)]">
|
|
<p> {i18n.t('kubernetes.resourcevisuale.s8fe0aeb0')} </p>
|
|
<Button variant="outline" size="sm" className="mt-4" onClick={() => setMode('yaml')}> {i18n.t('kubernetes.resourcevisuale.s54c78d4f')} </Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ModalOverlay>
|
|
);
|
|
}
|
|
|