202 lines
7.2 KiB
TypeScript
202 lines
7.2 KiB
TypeScript
// YamlViewer — YAML 查看/编辑(默认精简视图,工具栏:复制 / 编辑 / 保存)// Logan.Li
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { X, Loader2 } 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 { parseEditableManifest, parseEditableManifests, prepareYamlForSave } from './k8s-manifest-utils';
|
||
import { getYamlResourceConfig, yamlDownloadFilename } from './k8s-yaml-resource-config';
|
||
import { YamlEditorToolbar } from './YamlEditorToolbar';
|
||
import { YamlCodeMirror } from './YamlCodeMirror';
|
||
import { downloadYamlFile } from '@/utils/export';
|
||
import type { JsonData } from '@/types/common';
|
||
|
||
interface YamlViewerProps {
|
||
clusterCode: string;
|
||
namespace: string;
|
||
resource: string;
|
||
name: string;
|
||
onClose: () => void;
|
||
onSaved?: () => void;
|
||
autoEdit?: boolean;
|
||
inline?: boolean;
|
||
initialYaml?: string;
|
||
/** 覆盖资源配置的只读行为(Pod 默认只读)// Logan.Li */
|
||
readOnly?: boolean;
|
||
/** 自定义加载(如 Deployment + Service 多文档)// Logan.Li */
|
||
loadYamlFn?: () => Promise<string>;
|
||
/** 自定义保存(如 Deployment + Service 多文档)// Logan.Li */
|
||
saveYamlFn?: (yaml: string) => Promise<void>;
|
||
/** 资源描述补充(如 "含关联 Service")// Logan.Li */
|
||
resourceHint?: string;
|
||
}
|
||
|
||
export function YamlViewer({
|
||
clusterCode, namespace, resource, name, onClose, onSaved,
|
||
autoEdit = false, inline = false, initialYaml, readOnly: readOnlyProp,
|
||
loadYamlFn, saveYamlFn, resourceHint,
|
||
}: YamlViewerProps) {
|
||
const { toast } = useToast();
|
||
const resCfg = getYamlResourceConfig(resource);
|
||
const readOnly = readOnlyProp ?? resCfg.readOnly;
|
||
const [rawYaml, setRawYaml] = useState(initialYaml || '');
|
||
const [displayYaml, setDisplayYaml] = useState('');
|
||
const [editYaml, setEditYaml] = useState('');
|
||
const [loading, setLoading] = useState(!initialYaml);
|
||
const [saving, setSaving] = useState(false);
|
||
const [editing, setEditing] = useState(autoEdit && !readOnly);
|
||
const [copied, setCopied] = useState(false);
|
||
const [showRaw, setShowRaw] = useState(false);
|
||
|
||
const applyStripped = useCallback(async (text: string) => {
|
||
const { yaml: stripped } = text.includes('\n---')
|
||
? await parseEditableManifests(text)
|
||
: await parseEditableManifest(text);
|
||
setDisplayYaml(stripped);
|
||
setEditYaml(stripped);
|
||
return stripped;
|
||
}, []);
|
||
|
||
const loadYaml = useCallback(async () => {
|
||
if (initialYaml) {
|
||
setRawYaml(initialYaml);
|
||
await applyStripped(initialYaml);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const text = loadYamlFn
|
||
? await loadYamlFn()
|
||
: await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
|
||
setRawYaml(text);
|
||
await applyStripped(text);
|
||
} catch (e) {
|
||
toast({ title: '获取YAML失败', description: (e as JsonData).message, variant: 'destructive' });
|
||
const err = `# Error: ${(e as Error).message}`;
|
||
setRawYaml(err);
|
||
setDisplayYaml(err);
|
||
setEditYaml(err);
|
||
} finally { setLoading(false); }
|
||
}, [clusterCode, namespace, resource, name, initialYaml, loadYamlFn, applyStripped, toast]);
|
||
|
||
useEffect(() => { loadYaml(); }, [loadYaml]);
|
||
|
||
const enterEdit = () => {
|
||
setEditYaml(displayYaml);
|
||
setEditing(true);
|
||
setShowRaw(false);
|
||
};
|
||
|
||
const cancelEdit = async () => {
|
||
setEditing(false);
|
||
await loadYaml();
|
||
};
|
||
|
||
const saveYaml = async () => {
|
||
setSaving(true);
|
||
try {
|
||
const yamlStr = await prepareYamlForSave(editYaml);
|
||
if (saveYamlFn) {
|
||
await saveYamlFn(yamlStr);
|
||
} else {
|
||
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
|
||
}
|
||
toast({ title: '保存成功', description: saveYamlFn ? '多文档 YAML 已应用' : `${resource}/${name} 已更新` });
|
||
setEditing(false);
|
||
setRawYaml(yamlStr);
|
||
setDisplayYaml(yamlStr);
|
||
setEditYaml(yamlStr);
|
||
onSaved?.();
|
||
} catch (e) {
|
||
toast({ title: '保存失败', description: (e as Error)?.message || String(e), variant: 'destructive' });
|
||
} finally { setSaving(false); }
|
||
};
|
||
|
||
const currentYaml = () => (editing ? editYaml : (showRaw ? rawYaml : displayYaml));
|
||
|
||
const copyYaml = async () => {
|
||
await navigator.clipboard.writeText(currentYaml());
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
};
|
||
|
||
const downloadYaml = () => {
|
||
downloadYamlFile(yamlDownloadFilename(resource, namespace, name), currentYaml());
|
||
toast({ title: '已下载', description: yamlDownloadFilename(resource, namespace, name) });
|
||
};
|
||
|
||
const display = editing ? editYaml : (showRaw ? rawYaml : displayYaml);
|
||
|
||
const toolbar = (
|
||
<YamlEditorToolbar
|
||
editing={editing}
|
||
saving={saving}
|
||
copied={copied}
|
||
showRaw={showRaw}
|
||
compact={inline}
|
||
readOnly={readOnly}
|
||
onCopy={copyYaml}
|
||
onDownload={downloadYaml}
|
||
onEdit={enterEdit}
|
||
onCancel={cancelEdit}
|
||
onSave={saveYaml}
|
||
onToggleRaw={() => setShowRaw((v) => !v)}
|
||
/>
|
||
);
|
||
|
||
const editor = loading ? (
|
||
<div className="flex items-center justify-center h-full min-h-[200px]">
|
||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||
</div>
|
||
) : (
|
||
<YamlCodeMirror
|
||
value={display}
|
||
editing={editing}
|
||
fill
|
||
fontSize={inline ? '12px' : '13px'}
|
||
onChange={(val) => editing && setEditYaml(val)}
|
||
/>
|
||
);
|
||
|
||
if (inline) {
|
||
return (
|
||
<div className="flex flex-col gap-3 min-h-[400px] h-[calc(100vh-220px)] max-h-[720px]">
|
||
<div className="flex items-center justify-between gap-3 flex-wrap shrink-0">
|
||
<p className="text-xs text-slate-500">
|
||
<code className="text-slate-400">{resource}/{name}</code>
|
||
{resourceHint ? <span className="text-slate-600"> · {resourceHint}</span> : null}
|
||
{' · '}{namespace}
|
||
</p>
|
||
{toolbar}
|
||
</div>
|
||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">{editor}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<ModalOverlay onClose={onClose}>
|
||
<div
|
||
className="bg-slate-900 border border-slate-700 rounded-xl w-[92vw] max-w-5xl h-[85vh] flex flex-col shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between p-4 border-b border-slate-700 gap-3 shrink-0">
|
||
<div className="min-w-0 flex-1">
|
||
<h2 className="text-lg font-bold text-slate-100 truncate" title={`${resCfg.label} YAML — ${name}`}>
|
||
{resCfg.label} YAML — {name}
|
||
{readOnly && <span className="ml-2 text-xs font-normal text-slate-500">只读</span>}
|
||
</h2>
|
||
<p className="text-xs text-slate-500 truncate" title={`${namespace} · ${clusterCode}`}>{namespace} · {clusterCode}</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
{toolbar}
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden p-4">{editor}</div>
|
||
</div>
|
||
</ModalOverlay>
|
||
);
|
||
} |