260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
// YamlViewer.tsx — YAML 查看/编辑器(CodeMirror 6 语法高亮)
|
||
// 支持:语法高亮 + 行号 + 基本校验 + 编辑/保存 + 只读字段清理
|
||
// 作者: Logan <admin@attacker.club>
|
||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||
import { X, Copy, Check, Save, RefreshCw, Loader2, Sparkles } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { useToast } from '@/hooks/use-toast';
|
||
import { kubernetesApi } from '@/services/api';
|
||
import { cleanYAML } from './k8s-yaml-utils';
|
||
import CodeMirror from '@uiw/react-codemirror';
|
||
import { yaml } from '@codemirror/lang-yaml';
|
||
import { oneDark } from '@codemirror/theme-one-dark';
|
||
import { EditorView } from '@codemirror/view';
|
||
|
||
interface YamlViewerProps {
|
||
clusterCode: string;
|
||
namespace: string;
|
||
resource: string; // e.g. "deployments", "services"
|
||
name: string;
|
||
onClose: () => void;
|
||
onSaved?: () => void;
|
||
/** 进入编辑模式前是否清理只读/噪音字段(managedFields/uid/status 等) */
|
||
cleanBeforeEdit?: boolean;
|
||
/** 打开即自动进入编辑模式(编辑按钮专用入口) */
|
||
autoEdit?: boolean;
|
||
/** 内联模式(非弹窗)— 用于 YAML 编辑模式 Tab */
|
||
inline?: boolean;
|
||
/** 内联模式下 YAML 内容直接传入 */
|
||
initialYaml?: string;
|
||
}
|
||
|
||
export function YamlViewer({ clusterCode, namespace, resource, name, onClose, onSaved, cleanBeforeEdit = false, autoEdit = false, inline = false, initialYaml }: YamlViewerProps) {
|
||
const { toast } = useToast();
|
||
const [rawYaml, setRawYaml] = useState(initialYaml || ''); // 原始(只读展示用)
|
||
const [editYaml, setEditYaml] = useState(''); // 编辑态(可能被清理过)
|
||
const [loading, setLoading] = useState(!initialYaml);
|
||
const [saving, setSaving] = useState(false);
|
||
const [editing, setEditing] = useState(autoEdit); // autoEdit → 初始即编辑态
|
||
const [copied, setCopied] = useState(false);
|
||
// autoEdit 时隐式启用清理(编辑入口必须有清理,否则 status/managedFields 会被 K8s 拒绝)
|
||
const effectiveClean = cleanBeforeEdit || autoEdit;
|
||
|
||
const loadYaml = async () => {
|
||
if (initialYaml) {
|
||
setRawYaml(initialYaml);
|
||
setEditYaml(effectiveClean ? cleanYAML(initialYaml) : initialYaml);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
|
||
setRawYaml(text);
|
||
setEditYaml(effectiveClean ? cleanYAML(text) : text);
|
||
} catch (e: any) {
|
||
toast({ title: '获取YAML失败', description: e.message, variant: 'destructive' });
|
||
const err = `# Error: ${e.message}`;
|
||
setRawYaml(err);
|
||
setEditYaml(err);
|
||
} finally { setLoading(false); }
|
||
};
|
||
|
||
const enterEdit = () => {
|
||
const cleaned = effectiveClean ? cleanYAML(rawYaml) : rawYaml;
|
||
setEditYaml(cleaned);
|
||
setEditing(true);
|
||
if (effectiveClean) {
|
||
toast({ title: '已清理只读字段', description: '已剥离 status / managedFields / uid 等' });
|
||
}
|
||
};
|
||
|
||
// 视图模式:当 cleanBeforeEdit 时,只读视图也默认展示精简版(剥离 status/managedFields/uid/resourceVersion)
|
||
// 用户可点击「原始」按钮切换回未处理的完整服务端输出
|
||
const [showRaw, setShowRaw] = useState(false);
|
||
|
||
const saveYaml = async () => {
|
||
setSaving(true);
|
||
try {
|
||
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, editYaml);
|
||
toast({ title: 'YAML已应用', description: `${resource}/${name} 更新成功` });
|
||
setEditing(false);
|
||
setRawYaml(editYaml);
|
||
onSaved?.();
|
||
} catch (e: any) {
|
||
toast({ title: '保存失败', description: e.message, variant: 'destructive' });
|
||
} finally { setSaving(false); }
|
||
};
|
||
|
||
const copyYaml = async () => {
|
||
const content = editing ? editYaml : rawYaml;
|
||
await navigator.clipboard.writeText(content);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
};
|
||
|
||
useEffect(() => { loadYaml(); }, []);
|
||
|
||
// 编辑态用 editYaml;只读态根据 effectiveClean + showRaw 决定是否清理
|
||
const display = editing
|
||
? editYaml
|
||
: (effectiveClean && !showRaw ? cleanYAML(rawYaml) : rawYaml);
|
||
|
||
// CodeMirror 主题扩展
|
||
const extensions = useMemo(() => [
|
||
yaml(),
|
||
EditorView.lineWrapping,
|
||
...(editing ? [] : [EditorView.editable.of(false)]),
|
||
], [editing]);
|
||
|
||
// 内联模式 — 不显示外层弹窗,适合嵌入 Tab 页面
|
||
if (inline) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs text-slate-500">
|
||
<code className="text-slate-400">{resource}/{name}</code> · {namespace} · {clusterCode}
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={copyYaml}>
|
||
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
|
||
{copied ? '已复制' : '复制'}
|
||
</Button>
|
||
{effectiveClean && !editing && (
|
||
<Button variant="outline" size="sm" className={`h-7 text-xs ${showRaw ? 'border-slate-700 text-slate-500' : 'border-emerald-700/50 text-emerald-400'}`}
|
||
onClick={() => setShowRaw(v => !v)} title="切换 精简/原始 视图">
|
||
<Sparkles className="h-3 w-3 mr-1" />{showRaw ? '原始' : '精简'}
|
||
</Button>
|
||
)}
|
||
{!editing ? (
|
||
<>
|
||
{effectiveClean && (
|
||
<Button variant="outline" size="sm" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={enterEdit} title="清理 status / managedFields 等只读字段后编辑">
|
||
<Sparkles className="h-3 w-3 mr-1 text-amber-400" />编辑
|
||
</Button>
|
||
)}
|
||
{!effectiveClean && (
|
||
<Button variant="outline" size="sm" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => setEditing(true)}>
|
||
编辑
|
||
</Button>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Button variant="outline" size="sm" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => { setEditing(false); loadYaml(); }}>
|
||
取消
|
||
</Button>
|
||
<Button size="sm" className="h-7 bg-blue-600 hover:bg-blue-700 text-xs" onClick={saveYaml} disabled={saving}>
|
||
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
|
||
保存
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="border border-slate-700 rounded-lg overflow-hidden">
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-96"><Loader2 className="h-6 w-6 animate-spin text-slate-500" /></div>
|
||
) : (
|
||
<CodeMirror
|
||
value={display}
|
||
height="600px"
|
||
theme={oneDark}
|
||
extensions={extensions}
|
||
onChange={(val) => editing && setEditYaml(val)}
|
||
basicSetup={{
|
||
lineNumbers: true,
|
||
foldGutter: true,
|
||
highlightActiveLine: editing,
|
||
highlightActiveLineGutter: editing,
|
||
autocompletion: true,
|
||
bracketMatching: true,
|
||
closeBrackets: true,
|
||
indentOnInput: true,
|
||
}}
|
||
style={{ fontSize: '12px' }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 弹窗模式(原有行为)
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[85vh] flex flex-col shadow-2xl" onClick={e => e.stopPropagation()}>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||
<div>
|
||
<h2 className="text-lg font-bold text-slate-100">{resource}/{name}</h2>
|
||
<p className="text-xs text-slate-500">{namespace} · {clusterCode}</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={copyYaml}>
|
||
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
|
||
{copied ? '已复制' : '复制'}
|
||
</Button>
|
||
{effectiveClean && !editing && (
|
||
<Button variant="outline" size="sm" className={`h-8 text-xs ${showRaw ? 'border-slate-700 text-slate-500' : 'border-emerald-700/50 text-emerald-400'}`}
|
||
onClick={() => setShowRaw(v => !v)} title="切换 精简/原始 视图">
|
||
<Sparkles className="h-3 w-3 mr-1" />{showRaw ? '原始' : '精简'}
|
||
</Button>
|
||
)}
|
||
{!editing ? (
|
||
<>
|
||
{effectiveClean && (
|
||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={enterEdit} title="清理 status / managedFields 等只读字段后编辑">
|
||
<Sparkles className="h-3 w-3 mr-1 text-amber-400" />清理并编辑
|
||
</Button>
|
||
)}
|
||
{!effectiveClean && (
|
||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => setEditing(true)}>
|
||
编辑
|
||
</Button>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300" onClick={() => { setEditing(false); loadYaml(); }}>
|
||
取消
|
||
</Button>
|
||
<Button size="sm" className="h-8 bg-blue-600 hover:bg-blue-700" onClick={saveYaml} disabled={saving}>
|
||
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
|
||
保存
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||
</div>
|
||
</div>
|
||
{/* Body */}
|
||
<div className="flex-1 overflow-auto p-4">
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-full"><Loader2 className="h-6 w-6 animate-spin text-slate-500" /></div>
|
||
) : (
|
||
<CodeMirror
|
||
value={display}
|
||
height="600px"
|
||
theme={oneDark}
|
||
extensions={extensions}
|
||
onChange={(val) => editing && setEditYaml(val)}
|
||
basicSetup={{
|
||
lineNumbers: true,
|
||
foldGutter: true,
|
||
highlightActiveLine: editing,
|
||
highlightActiveLineGutter: editing,
|
||
autocompletion: true,
|
||
bracketMatching: true,
|
||
closeBrackets: true,
|
||
indentOnInput: true,
|
||
}}
|
||
style={{ fontSize: '12px' }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|