feat(k8s): 统一 YAML 查看编辑并补齐 Pod/导出能力

- 抽取 YamlCodeMirror/YamlEditorToolbar,修复弹窗滚动与固定高度布局
- 对齐集群导出精简规则(Service/Ingress/Workload/PVC 注解等)
- Deployment/Pod/Service/Ingress 分资源配置:Pod 只读,支持单文件下载
- 修复 Deployment Pod YAML 弹窗未渲染,补全 PodsTab 与详情页 YAML 入口
- 命名空间列表页新增导出 YAML(tar.gz),与集群导出 API 一致
This commit is contained in:
Logan.Li committed 2026-06-26 15:25:46 +08:00
1 parent eeba677850
commit fd7b04b9d6
15 files changed
+651 -150

No files matched your search

+3
View File
@@ -11,4 +11,7 @@ export const namespaceApi = {
http.post(`/kubernetes/${clusterCode}/namespaces`, { name, labels: labels || {} }),
deleteNamespace: async (clusterCode: string, namespace: string) =>
http.delete(`/kubernetes/${clusterCode}/namespaces/${namespace}`),
/** 导出命名空间资源 tar.gz(Deployment/Service/Ingress/ConfigMap/Secret)// Logan.Li */
exportNamespaceResources: async (clusterCode: string, namespace: string) =>
http.download(`/kubernetes/${clusterCode}/namespace/${namespace}/export`, `${namespace}-resources.tar.gz`),
};
@@ -14,6 +14,7 @@ import { DeploymentPodsTab } from './deployment-detail/DeploymentPodsTab';
import { DeploymentLogsTab } from './deployment-detail/DeploymentLogsTab';
import { DeploymentHpaTab } from './deployment-detail/DeploymentHpaTab';
import { DeploymentPodModals } from './deployment-detail/DeploymentPodModals';
import { YamlViewer } from './YamlViewer';
export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBack }: DeploymentDetailProps) {
const { toast } = useToast();
@@ -33,6 +34,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
const [logsViewerOpen, setLogsViewerOpen] = useState(false);
const [fileBrowserPod, setFileBrowserPod] = useState<PodWithContainers | null>(null);
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const [yamlPodName, setYamlPodName] = useState<string | null>(null);
const [logsTabPod, setLogsTabPod] = useState<string>('');
const loadData = useCallback(async () => {
@@ -187,6 +189,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
onOpenLogsViewer={(pod) => { setLogsViewerPod(pod); setLogsViewerOpen(true); }}
onOpenExec={(pod) => { setExecPod(pod); setExecOpen(true); }}
onOpenFileBrowser={(pod) => { setFileBrowserPod(pod); setFileBrowserOpen(true); }}
onOpenYaml={setYamlPodName}
/>
<DeploymentLogsTab
pods={pods}
@@ -206,6 +209,17 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
/>
</Tabs>
{yamlPodName && (
<YamlViewer
clusterCode={clusterCode}
namespace={namespace}
resource="pods"
name={yamlPodName}
readOnly
onClose={() => setYamlPodName(null)}
/>
)}
<DeploymentPodModals
clusterCode={clusterCode}
namespace={namespace}
@@ -363,7 +363,6 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
namespace={namespace}
resource="deployments"
name={deploymentName}
cleanBeforeEdit
inline
onSaved={loadData}
onClose={() => onModeChange('view')}
@@ -387,7 +386,6 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
namespace={namespace}
resource="deployments"
name={deploymentName}
cleanBeforeEdit
onClose={() => setModal(null)}
onSaved={loadData}
/>
@@ -416,6 +414,18 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
</ModalShell>
)}
{/* ============ Pod YAML(只读,精简视图同集群导出) ============ */}
{modal?.kind === 'podYaml' && (
<YamlViewer
clusterCode={clusterCode}
namespace={namespace}
resource="pods"
name={modal.pod}
readOnly
onClose={() => setModal(null)}
/>
)}
{/* ============ Pod 日志 ============ */}
{modal?.kind === 'podLogs' && (
<PodLogsModal
@@ -31,6 +31,8 @@ type ResourceType =
// 支持可视化编辑/新建的资源类型 // Logan.Li
const VISUAL_RESOURCE_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets', 'pvcs'];
/** 支持命名空间 YAML 批量导出(对齐集群导出)// Logan.Li */
const EXPORT_YAML_TYPES: ResourceType[] = ['deployments', 'services', 'ingresses', 'configmaps', 'secrets'];
const VisualActions = ({ name, opts }: { name: string; opts: { onYaml?: (n: string) => void; onEdit?: (n: string) => void } }) => (
<div className="flex items-center gap-0.5">
@@ -71,10 +73,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML"
onClick={() => opts.onYaml?.(d.name)}><FileCode className="h-3.5 w-3.5" /></Button>
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" title="查看 YAML"
onClick={() => opts.onYaml?.(d.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</TableCell>
</TableRow>
),
@@ -318,7 +318,7 @@ export default function NamespaceResourcePage() {
const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>();
const location = useLocation();
const navigate = useNavigate();
const { toast: _toast } = useToast();
const { toast } = useToast();
const type = useMemo<ResourceType>(() => {
const segments = location.pathname.split('/');
const last = segments[segments.length - 1];
@@ -417,6 +417,15 @@ export default function NamespaceResourcePage() {
const handleCreate = () => setVisualItem({ resource: type, name: '', isCreate: true });
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, filtered);
const handleExportYaml = async () => {
if (!clusterCode || !namespace) return;
try {
await kubernetesApi.exportNamespaceResources(clusterCode, namespace);
toast({ title: '导出完成', description: `${namespace} 资源 YAML 已开始下载` });
} catch (e) {
toast({ title: '导出失败', description: (e as Error)?.message || String(e), variant: 'destructive' });
}
};
if (!config) return <div className="text-slate-400 p-8 text-center">未知资源类型: {type}</div>;
@@ -430,7 +439,6 @@ export default function NamespaceResourcePage() {
name={yamlItem.name}
onClose={() => setYamlItem(null)}
onSaved={loadData}
cleanBeforeEdit={true}
autoEdit={yamlItem.editMode === true}
/>
)}
@@ -482,6 +490,11 @@ export default function NamespaceResourcePage() {
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportJSON} title="导出JSON">
<Download className="h-3 w-3 mr-1" />JSON
</Button>
{EXPORT_YAML_TYPES.includes(type) && (
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={handleExportYaml} title="导出命名空间 YAML(tar.gz)">
<Download className="h-3 w-3 mr-1" />导出YAML
</Button>
)}
{VISUAL_RESOURCE_TYPES.includes(type) && (
<>
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={() => setShowYamlImport(true)}>
@@ -9,11 +9,12 @@ 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 CodeMirror from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark';
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;
@@ -33,23 +34,33 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
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);
setEditYaml(JSON.stringify(empty, null, 2));
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');
@@ -94,8 +105,12 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
toast({ title: '创建成功', description: 'YAML 已应用' });
} else {
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
toast({ title: 'YAML 已应用', description: `${resource}/${name} 更新成功` });
toast({ title: '保存成功', description: `${resource}/${name} 已更新` });
}
setYamlEditing(false);
setDisplayYaml(yamlStr);
setEditYaml(yamlStr);
if (!isCreate) setRawYaml(yamlStr);
onSaved?.();
onClose();
} catch (e) {
@@ -115,9 +130,31 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
};
const switchToYaml = async () => {
if (manifest) setEditYaml(await prepareManifestForSave(manifest));
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: any; color: string }> = {
configmaps: { title: 'ConfigMap', icon: FileText, color: 'text-blue-400' },
secrets: { title: 'Secret', icon: Shield, color: 'text-amber-400' },
@@ -131,7 +168,7 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
return (
<ModalOverlay onClose={onClose}>
<div
className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl w-[90vw] max-w-3xl max-h-[85vh] flex flex-col shadow-2xl"
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 */}
@@ -161,30 +198,47 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4">
<div className={`flex-1 min-h-0 p-4 ${mode === 'yaml' ? 'flex flex-col overflow-hidden' : 'overflow-auto'}`}>
{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="space-y-3">
<CodeMirror
value={editYaml}
height="550px"
theme={oneDark}
extensions={[yaml()]}
onChange={(val) => setEditYaml(val)}
basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: true, autocompletion: true }}
style={{ fontSize: '12px' }}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={async () => {
const { yaml: stripped } = await parseEditableManifest(rawYaml);
setEditYaml(stripped);
}}>重置</Button>
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSaveYaml} 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 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 ? (
<>
@@ -0,0 +1,90 @@
// YAML CodeMirror 封装:保证编辑区可滚动、撑满父容器 // Logan.Li
import { useRef, useState, useEffect, useMemo } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
const scrollTheme = EditorView.theme({
'&': { height: '100%' },
'.cm-scroller': { overflow: 'auto' },
});
export interface YamlCodeMirrorProps {
value: string;
onChange?: (value: string) => void;
editing?: boolean;
/** 撑满 flex 父容器(配合 flex-1 min-h-0)// Logan.Li */
fill?: boolean;
/** 固定高度,fill 时忽略 */
height?: string;
className?: string;
fontSize?: string;
}
export function YamlCodeMirror({
value,
onChange,
editing = true,
fill = false,
height = '480px',
className = '',
fontSize = '13px',
}: YamlCodeMirrorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [fillHeight, setFillHeight] = useState(400);
useEffect(() => {
if (!fill || !containerRef.current) return;
const el = containerRef.current;
const sync = () => setFillHeight(Math.max(200, el.clientHeight));
const ro = new ResizeObserver(sync);
ro.observe(el);
sync();
return () => ro.disconnect();
}, [fill]);
const extensions = useMemo(() => [
yaml(),
EditorView.lineWrapping,
scrollTheme,
...(editing ? [] : [EditorView.editable.of(false)]),
], [editing]);
const editorHeight = fill ? `${fillHeight}px` : height;
const editor = (
<CodeMirror
value={value}
height={editorHeight}
theme={oneDark}
extensions={extensions}
onChange={onChange}
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: editing,
highlightActiveLineGutter: editing,
autocompletion: editing,
bracketMatching: true,
closeBrackets: editing,
indentOnInput: editing,
}}
style={{ fontSize }}
/>
);
if (fill) {
return (
<div ref={containerRef} className={`flex-1 min-h-0 h-full overflow-hidden rounded-lg border border-slate-700 ${className}`}>
{editor}
</div>
);
}
return (
<div className={`overflow-auto rounded-lg border border-slate-700 ${className}`}>
{editor}
</div>
);
}
@@ -0,0 +1,64 @@
// YAML 查看/编辑统一工具栏:复制 · 下载 · 编辑 · 保存 // Logan.Li
import { Copy, Check, Save, Loader2, Pencil, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
export interface YamlEditorToolbarProps {
editing: boolean;
saving?: boolean;
copied?: boolean;
showRaw?: boolean;
compact?: boolean;
readOnly?: boolean;
showRawToggle?: boolean;
onCopy: () => void;
onDownload?: () => void;
onEdit: () => void;
onCancel: () => void;
onSave: () => void;
onToggleRaw?: () => void;
}
export function YamlEditorToolbar({
editing, saving = false, copied = false, showRaw = false, compact = false,
readOnly = false, showRawToggle = true,
onCopy, onDownload, onEdit, onCancel, onSave, onToggleRaw,
}: YamlEditorToolbarProps) {
const sz = compact ? 'h-7 text-xs' : 'h-8';
return (
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" className={`${sz} border-slate-700 text-slate-300`} onClick={onCopy}>
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
{copied ? '已复制' : '复制'}
</Button>
{onDownload && (
<Button variant="outline" size="sm" className={`${sz} border-slate-700 text-slate-300`} onClick={onDownload}>
<Download className="h-3 w-3 mr-1" />下载
</Button>
)}
{!readOnly && (
!editing ? (
<Button variant="outline" size="sm" className={`${sz} border-slate-700 text-slate-300`} onClick={onEdit}>
<Pencil className="h-3 w-3 mr-1" />编辑
</Button>
) : (
<>
<Button variant="outline" size="sm" className={`${sz} border-slate-700 text-slate-300`} onClick={onCancel}>取消</Button>
<Button size="sm" className={`${sz} bg-blue-600 hover:bg-blue-700`} onClick={onSave} disabled={saving}>
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
保存
</Button>
</>
)
)}
{showRawToggle && onToggleRaw && (
<button
type="button"
onClick={onToggleRaw}
className="text-[11px] text-slate-500 hover:text-slate-300 underline-offset-2 hover:underline"
>
{showRaw ? '精简视图' : '原始 YAML'}
</button>
)}
</div>
);
}
@@ -5,9 +5,7 @@ import { Button } from '@/components/ui/button';
import { ModalOverlay } from '@/components/ui/modal-overlay';
import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api';
import CodeMirror from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark';
import { YamlCodeMirror } from './YamlCodeMirror';
interface YamlImportDialogProps {
clusterCode: string;
@@ -39,7 +37,7 @@ export function YamlImportDialog({ clusterCode, onClose, onApplied }: YamlImport
return (
<ModalOverlay onClose={onClose}>
<div
className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl w-[92vw] max-w-4xl max-h-[85vh] flex flex-col shadow-2xl"
className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl w-[92vw] max-w-4xl h-[85vh] flex flex-col shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-[var(--dg-border)]">
@@ -52,19 +50,15 @@ export function YamlImportDialog({ clusterCode, onClose, onApplied }: YamlImport
</div>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
<div className="flex-1 overflow-auto p-4">
<CodeMirror
<div className="flex-1 min-h-0 flex flex-col overflow-hidden p-4">
<YamlCodeMirror
value={content}
height="420px"
theme={oneDark}
extensions={[yaml()]}
fill
fontSize="12px"
onChange={setContent}
placeholder="粘贴 YAML 内容..."
basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: true }}
style={{ fontSize: '12px' }}
/>
</div>
<div className="flex justify-end gap-2 p-4 border-t border-[var(--dg-border)]">
<div className="flex justify-end gap-2 p-4 border-t border-[var(--dg-border)] shrink-0">
<Button variant="outline" onClick={onClose}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={apply} disabled={applying}>
{applying ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Upload className="h-4 w-4 mr-1" />}
@@ -1,15 +1,15 @@
// YamlViewer — YAML 查看/编辑(默认精简视图,工具栏:复制 / 编辑 / 保存)// Logan.Li
import { useState, useEffect, useMemo, useCallback } from 'react';
import { X, Copy, Check, Save, Loader2, Pencil } from 'lucide-react';
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, prepareYamlForSave } from './k8s-manifest-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';
import { getYamlResourceConfig, yamlDownloadFilename } from './k8s-yaml-resource-config';
import { YamlEditorToolbar } from './YamlEditorToolbar';
import { YamlCodeMirror } from './YamlCodeMirror';
import { downloadYamlFile } from '@/utils/export';
interface YamlViewerProps {
clusterCode: string;
@@ -18,58 +18,26 @@ interface YamlViewerProps {
name: string;
onClose: () => void;
onSaved?: () => void;
/** @deprecated 始终默认精简,保留兼容 */
cleanBeforeEdit?: boolean;
autoEdit?: boolean;
inline?: boolean;
initialYaml?: string;
}
function YamlToolbar({
editing, saving, copied, showRaw, inline,
onCopy, onEdit, onCancel, onSave, onToggleRaw,
}: {
editing: boolean; saving: boolean; copied: boolean; showRaw: boolean; inline?: boolean;
onCopy: () => void; onEdit: () => void; onCancel: () => void; onSave: () => void; onToggleRaw: () => void;
}) {
const sz = inline ? 'h-7 text-xs' : 'h-8';
return (
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" className={`${sz} border-slate-700 text-slate-300`} onClick={onCopy}>
{copied ? <Check className="h-3 w-3 mr-1 text-emerald-400" /> : <Copy className="h-3 w-3 mr-1" />}
{copied ? '已复制' : '复制'}
</Button>
{!editing ? (
<Button variant="outline" size="sm" className={`${sz} border-slate-700 text-slate-300`} onClick={onEdit}>
<Pencil className="h-3 w-3 mr-1" />编辑
</Button>
) : (
<>
<Button variant="outline" size="sm" className={`${sz} border-slate-700 text-slate-300`} onClick={onCancel}>取消</Button>
<Button size="sm" className={`${sz} bg-blue-600 hover:bg-blue-700`} onClick={onSave} disabled={saving}>
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
保存
</Button>
</>
)}
<button type="button" onClick={onToggleRaw} className="text-[11px] text-slate-500 hover:text-slate-300 underline-offset-2 hover:underline">
{showRaw ? '精简视图' : '原始 YAML'}
</button>
</div>
);
/** 覆盖资源配置的只读行为(Pod 默认只读)// Logan.Li */
readOnly?: boolean;
}
export function YamlViewer({
clusterCode, namespace, resource, name, onClose, onSaved,
autoEdit = false, inline = false, initialYaml,
autoEdit = false, inline = false, initialYaml, readOnly: readOnlyProp,
}: 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);
const [editing, setEditing] = useState(autoEdit && !readOnly);
const [copied, setCopied] = useState(false);
const [showRaw, setShowRaw] = useState(false);
@@ -130,29 +98,31 @@ export function YamlViewer({
} finally { setSaving(false); }
};
const currentYaml = () => (editing ? editYaml : (showRaw ? rawYaml : displayYaml));
const copyYaml = async () => {
const content = editing ? editYaml : (showRaw ? rawYaml : displayYaml);
await navigator.clipboard.writeText(content);
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 extensions = useMemo(() => [
yaml(),
EditorView.lineWrapping,
...(editing ? [] : [EditorView.editable.of(false)]),
], [editing]);
const toolbar = (
<YamlToolbar
<YamlEditorToolbar
editing={editing}
saving={saving}
copied={copied}
showRaw={showRaw}
inline={inline}
compact={inline}
readOnly={readOnly}
onCopy={copyYaml}
onDownload={downloadYaml}
onEdit={enterEdit}
onCancel={cancelEdit}
onSave={saveYaml}
@@ -161,40 +131,29 @@ export function YamlViewer({
);
const editor = loading ? (
<div className={`flex items-center justify-center ${inline ? 'h-96' : 'h-full'}`}>
<div className="flex items-center justify-center h-full min-h-[200px]">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
</div>
) : (
<CodeMirror
<YamlCodeMirror
value={display}
height={inline ? '600px' : '100%'}
theme={oneDark}
extensions={extensions}
editing={editing}
fill
fontSize={inline ? '12px' : '13px'}
onChange={(val) => editing && setEditYaml(val)}
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: editing,
highlightActiveLineGutter: editing,
autocompletion: editing,
bracketMatching: true,
closeBrackets: editing,
indentOnInput: editing,
}}
style={{ fontSize: inline ? '12px' : '13px', height: inline ? undefined : '100%' }}
/>
);
if (inline) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<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> · {namespace}
</p>
{toolbar}
</div>
<div className="border border-slate-700 rounded-lg overflow-hidden">{editor}</div>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">{editor}</div>
</div>
);
}
@@ -202,12 +161,15 @@ export function YamlViewer({
return (
<ModalOverlay onClose={onClose}>
<div
className="bg-slate-900 border border-slate-700 rounded-xl w-[92vw] max-w-5xl max-h-[85vh] flex flex-col shadow-2xl"
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">
<div className="flex items-center justify-between p-4 border-b border-slate-700 gap-3 shrink-0">
<div className="min-w-0">
<h2 className="text-lg font-bold text-slate-100 truncate">YAML — {resource}/{name}</h2>
<h2 className="text-lg font-bold text-slate-100 truncate">
{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">{namespace} · {clusterCode}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
@@ -215,7 +177,7 @@ export function YamlViewer({
<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 overflow-hidden p-4">{editor}</div>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden p-4">{editor}</div>
</div>
</ModalOverlay>
);
@@ -1,5 +1,5 @@
// DeploymentPodsTab.tsx — Deployment 详情 Pod 列表 Tab // Logan.Li
import { FileText, Terminal, FolderOpen } from 'lucide-react';
import { FileText, Terminal, FolderOpen, FileCode } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -15,9 +15,10 @@ export interface DeploymentPodsTabProps {
onOpenLogsViewer: (pod: PodWithContainers) => void;
onOpenExec: (pod: PodWithContainers) => void;
onOpenFileBrowser: (pod: PodWithContainers) => void;
onOpenYaml: (podName: string) => void;
}
export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFileBrowser }: DeploymentPodsTabProps) {
export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFileBrowser, onOpenYaml }: DeploymentPodsTabProps) {
return (
<TabsContent value="pods">
<Card className="bg-slate-900 border-slate-800">
@@ -73,6 +74,11 @@ export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFi
onClick={() => onOpenFileBrowser({ name: pod.name, containers: extractContainers(pod) })}>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
title="查看 YAML"
onClick={() => onOpenYaml(pod.name)}>
<FileCode className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
@@ -3,14 +3,14 @@ import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
ChevronDown, ChevronRight, Terminal, FileText, HardDrive,
ChevronDown, ChevronRight, Terminal, FileText, HardDrive, FileCode,
} from 'lucide-react';
import { formatK8sTime, statusColor } from '../k8s-yaml-utils';
import { getPodTimeline, PodConditionsRow } from './helpers';
import type { DeploymentViewProps } from './types';
export function PodsTab({
clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodFileBrowser,
clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser,
expandedPod, setExpandedPod,
}: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
if (pods.length === 0) {
@@ -52,6 +52,9 @@ export function PodsTab({
<Button variant="ghost" size="sm" className="h-7 text-xs text-amber-400" onClick={() => onShowPodFileBrowser(pod)}>
<HardDrive className="h-3.5 w-3.5 mr-1" />文件
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-slate-400 hover:text-blue-400" onClick={() => onShowPodYaml(pod)}>
<FileCode className="h-3.5 w-3.5 mr-1" />YAML
</Button>
</div>
{/* Expanded: timeline */}
{expanded && (
@@ -4,12 +4,17 @@ import { cleanYAML } from './k8s-yaml-utils';
/** 主流方案:编辑/展示时应排除的 metadata 注解 // Logan.Li */
const STRIP_ANNOTATIONS = new Set([
'kubectl.kubernetes.io/last-applied-configuration',
'deployment.kubernetes.io/revision',
'kubectl.kubernetes.io/change-cause',
'kubectl.kubernetes.io/restartedAt',
'deployment.kubernetes.io/revision',
'deployment.kubernetes.io/desired-replicas',
'deployment.kubernetes.io/max-replicas',
'control-plane.alpha.kubernetes.io/leader',
'autoscaling.alpha.kubernetes.io/conditions',
'kubernetes.io/config.hash',
'kubeadm.kubernetes.io/etcd.advertise-client-urls',
'pv.kubernetes.io/bind-completed',
'pv.kubernetes.io/bound-by-controller',
]);
/** metadata 运行时字段(kubectl/Kuboard/Lens 等均不展示)// Logan.Li */
@@ -18,6 +23,74 @@ const STRIP_METADATA_KEYS = [
'selfLink', 'deletionTimestamp', 'ownerReferences', 'finalizers', 'clusterName', 'generateName',
] as const;
/** metadata.labels 中控制器自动写入的标签 // Logan.Li */
const STRIP_LABEL_KEYS = new Set([
'pod-template-hash',
'controller-revision-hash',
]);
/** 系统卷名前缀(ServiceAccount token 等)// Logan.Li */
const SYSTEM_VOLUME_PREFIXES = ['kube-api-access-', 'default-token-', 'kube-root-ca.crt'];
/** 系统挂载路径 // Logan.Li */
const SYSTEM_MOUNT_PATHS = [
'/var/run/secrets/kubernetes.io/serviceaccount',
'/var/run/secrets/sa',
];
const POD_SPEC_DEFAULTS: Record<string, unknown> = {
dnsPolicy: 'ClusterFirst',
restartPolicy: 'Always',
schedulerName: 'default-scheduler',
terminationGracePeriodSeconds: 30,
enableServiceLinks: true,
serviceAccountName: 'default',
automountServiceAccountToken: true,
priority: 0,
preemptionPolicy: 'PreemptLowerPriority',
};
const DEPLOYMENT_SPEC_DEFAULTS: Record<string, unknown> = {
progressDeadlineSeconds: 600,
revisionHistoryLimit: 10,
};
const CONTAINER_DEFAULTS: Record<string, unknown> = {
terminationMessagePath: '/dev/termination-log',
terminationMessagePolicy: 'File',
stdin: false,
tty: false,
stdinOnce: false,
};
const SERVICE_SPEC_DEFAULTS: Record<string, unknown> = {
sessionAffinity: 'None',
externalTrafficPolicy: 'Cluster',
internalTrafficPolicy: 'Cluster',
ipFamilyPolicy: 'SingleStack',
allocateLoadBalancerNodePorts: true,
};
/** Service 运行时分配字段 // Logan.Li */
const SERVICE_RUNTIME_KEYS = [
'clusterIP', 'clusterIPs', 'ipFamilies', 'healthCheckNodePort',
] as const;
function isEmptyCollection(val: unknown): boolean {
if (Array.isArray(val)) return val.length === 0;
if (val && typeof val === 'object') return Object.keys(val).length === 0;
return false;
}
function matchesDefault(val: unknown, def: unknown): boolean {
if (def === val) return true;
if (typeof def === 'number' && typeof val === 'string' && String(def) === val) return true;
if (typeof def === 'boolean' && typeof val === 'string') {
return (def && val === 'true') || (!def && val === 'false');
}
return false;
}
function stripMetadataBlock(meta: Record<string, any> | undefined): void {
if (!meta || typeof meta !== 'object') return;
for (const k of STRIP_METADATA_KEYS) delete meta[k];
@@ -28,31 +101,153 @@ function stripMetadataBlock(meta: Record<string, any> | undefined): void {
if (Object.keys(meta.annotations).length === 0) delete meta.annotations;
}
if (meta.labels) {
delete meta.labels['pod-template-hash'];
for (const k of STRIP_LABEL_KEYS) delete meta.labels[k];
if (Object.keys(meta.labels).length === 0) delete meta.labels;
}
}
/** 递归移除空对象(如 securityContext: {})// Logan.Li */
function pruneEmptyObjects(node: unknown): void {
/** 递归移除空对象/空数组 // Logan.Li */
function pruneEmpty(node: unknown): void {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
node.forEach(pruneEmptyObjects);
for (let i = node.length - 1; i >= 0; i--) {
pruneEmpty(node[i]);
if (isEmptyCollection(node[i])) node.splice(i, 1);
}
return;
}
const obj = node as Record<string, unknown>;
for (const key of Object.keys(obj)) {
const val = obj[key];
if (val && typeof val === 'object') {
if (Array.isArray(val)) val.forEach(pruneEmptyObjects);
else pruneEmptyObjects(val);
if (!Array.isArray(val) && typeof val === 'object' && Object.keys(val as object).length === 0) {
delete obj[key];
if (val && typeof val === 'object') pruneEmpty(val);
if (isEmptyCollection(val)) delete obj[key];
}
}
function stripObjectDefaults(obj: Record<string, any> | undefined, defaults: Record<string, unknown>): void {
if (!obj || typeof obj !== 'object') return;
for (const [key, def] of Object.entries(defaults)) {
if (key in obj && matchesDefault(obj[key], def)) delete obj[key];
}
}
function isSystemVolumeName(name: string): boolean {
return SYSTEM_VOLUME_PREFIXES.some((p) => name.startsWith(p) || name === p);
}
function isSystemMountPath(path: string): boolean {
return SYSTEM_MOUNT_PATHS.some((p) => path === p || path.startsWith(`${p}/`));
}
/** 移除系统卷及对应 volumeMount // Logan.Li */
function stripSystemVolumes(spec: Record<string, any> | undefined): void {
if (!spec || typeof spec !== 'object') return;
const volumes: Array<{ name?: string }> = spec.volumes;
if (Array.isArray(volumes)) {
const systemNames = new Set(
volumes.filter((v) => v?.name && isSystemVolumeName(v.name)).map((v) => v.name as string),
);
spec.volumes = volumes.filter((v) => !v?.name || !isSystemVolumeName(v.name));
if (spec.volumes.length === 0) delete spec.volumes;
const stripMounts = (containers: Array<Record<string, any>> | undefined) => {
if (!Array.isArray(containers)) return;
for (const c of containers) {
if (!Array.isArray(c.volumeMounts)) continue;
c.volumeMounts = c.volumeMounts.filter((m) => {
if (m?.name && systemNames.has(m.name)) return false;
if (m?.mountPath && isSystemMountPath(String(m.mountPath))) return false;
return true;
});
if (c.volumeMounts.length === 0) delete c.volumeMounts;
}
};
stripMounts(spec.containers);
stripMounts(spec.initContainers);
stripMounts(spec.ephemeralContainers);
}
}
function stripContainerDefaults(container: Record<string, any>): void {
if (!container || typeof container !== 'object') return;
stripObjectDefaults(container, CONTAINER_DEFAULTS);
if (container.imagePullPolicy) {
const tag = String(container.image || '').split(':').pop() || '';
const isLatest = !String(container.image || '').includes(':') || tag === 'latest';
const expected = isLatest ? 'Always' : 'IfNotPresent';
if (container.imagePullPolicy === expected) delete container.imagePullPolicy;
}
if (container.resources && isEmptyCollection(container.resources)) delete container.resources;
if (container.securityContext && isEmptyCollection(container.securityContext)) delete container.securityContext;
}
function stripPodSpecDefaults(spec: Record<string, any> | undefined): void {
if (!spec || typeof spec !== 'object') return;
stripObjectDefaults(spec, POD_SPEC_DEFAULTS);
if ('serviceAccount' in spec) delete spec.serviceAccount;
if (spec.nodeName) delete spec.nodeName;
stripSystemVolumes(spec);
for (const listKey of ['containers', 'initContainers', 'ephemeralContainers'] as const) {
const list = spec[listKey];
if (!Array.isArray(list)) continue;
for (const c of list) stripContainerDefaults(c);
}
}
/** Deployment 默认滚动更新策略 // Logan.Li */
function stripDeploymentStrategy(spec: Record<string, any> | undefined): void {
if (!spec?.strategy || spec.strategy.type !== 'RollingUpdate') return;
const ru = spec.strategy.rollingUpdate;
if (!ru || typeof ru !== 'object') return;
const unavailable = ru.maxUnavailable;
const surge = ru.maxSurge;
const defaultUnavailable = unavailable === '25%' || unavailable === 0 || unavailable === '0';
const defaultSurge = surge === '25%' || surge === 1 || surge === '1';
if (defaultUnavailable && defaultSurge && Object.keys(ru).length <= 2) {
delete spec.strategy;
}
}
function stripServiceSpec(spec: Record<string, any> | undefined): void {
if (!spec || typeof spec !== 'object') return;
for (const k of SERVICE_RUNTIME_KEYS) delete spec[k];
stripObjectDefaults(spec, SERVICE_SPEC_DEFAULTS);
const svcType = spec.type || 'ClusterIP';
if (Array.isArray(spec.ports)) {
for (const p of spec.ports) {
if (!p || typeof p !== 'object') continue;
if (svcType === 'ClusterIP' || p.nodePort === 0) delete p.nodePort;
if (p.protocol === 'TCP' && Object.keys(p).length === 3) delete p.protocol;
}
}
}
/** Ingress 精简(对齐集群导出 cleanResourceYAML)// Logan.Li */
function stripIngressSpec(spec: Record<string, any> | undefined): void {
if (!spec || typeof spec !== 'object') return;
if (!spec.ingressClassName) delete spec.ingressClassName;
}
function stripWorkloadTemplate(spec: Record<string, any> | undefined): void {
if (!spec?.template || typeof spec.template !== 'object') return;
if (spec.template.metadata) stripMetadataBlock(spec.template.metadata);
const tplSpec = spec.template.spec;
if (tplSpec) stripPodSpecDefaults(tplSpec);
}
function stripKindDefaults(manifest: Record<string, any>): void {
const kind = String(manifest.kind || '').toLowerCase();
if (!manifest.spec) return;
if (kind === 'deployment' || kind === 'statefulset' || kind === 'daemonset') {
stripObjectDefaults(manifest.spec, DEPLOYMENT_SPEC_DEFAULTS);
if (kind === 'deployment') stripDeploymentStrategy(manifest.spec);
stripWorkloadTemplate(manifest.spec);
}
if (kind === 'service') stripServiceSpec(manifest.spec);
if (kind === 'ingress') stripIngressSpec(manifest.spec);
if (kind === 'pod') stripPodSpecDefaults(manifest.spec);
}
/** 对象级剥离运行时字段 // Logan.Li */
export function stripRuntimeFields(manifest: Record<string, any>): Record<string, any> {
const cloned = JSON.parse(JSON.stringify(manifest));
@@ -60,8 +255,8 @@ export function stripRuntimeFields(manifest: Record<string, any>): Record<string
stripMetadataBlock(cloned.metadata);
const tpl = cloned.spec?.template;
if (tpl?.metadata) stripMetadataBlock(tpl.metadata);
if (cloned.spec?.nodeName) delete cloned.spec.nodeName;
pruneEmptyObjects(cloned);
stripKindDefaults(cloned);
pruneEmpty(cloned);
return cloned;
}
@@ -0,0 +1,33 @@
// 各资源 YAML 查看/编辑行为(对齐 backend k8s_export.go 资源分类)// Logan.Li
export interface YamlResourceConfig {
label: string;
exportFolder: string;
/** Pod 等由控制器管理的资源仅查看 // Logan.Li */
readOnly: boolean;
visualEdit: boolean;
}
/** 与集群导出 tar 内目录一致:deployments / services / ingresses // Logan.Li */
export const YAML_RESOURCE_CONFIG: Record<string, YamlResourceConfig> = {
deployments: { label: 'Deployment', exportFolder: 'deployments', readOnly: false, visualEdit: false },
pods: { label: 'Pod', exportFolder: 'pods', readOnly: true, visualEdit: false },
services: { label: 'Service', exportFolder: 'services', readOnly: false, visualEdit: true },
ingresses: { label: 'Ingress', exportFolder: 'ingresses', readOnly: false, visualEdit: true },
};
export function getYamlResourceConfig(resource: string): YamlResourceConfig {
const key = resource.replace(/\/$/, '').toLowerCase();
return YAML_RESOURCE_CONFIG[key] ?? {
label: key,
exportFolder: key,
readOnly: false,
visualEdit: false,
};
}
/** 单文件下载名:与导出 tar 路径风格一致 ns/folder/name.yaml // Logan.Li */
export function yamlDownloadFilename(resource: string, namespace: string, name: string): string {
const cfg = getYamlResourceConfig(resource);
return `${namespace}-${cfg.exportFolder}-${name}.yaml`;
}
@@ -22,10 +22,10 @@ export function cleanYAML(yaml: string): string {
// ===== 移除配置 =====
const METADATA_LINE_KEYS = new Set([
'uid', 'resourceVersion', 'creationTimestamp', 'generation',
'selfLink', 'clusterName', 'generateName',
'selfLink', 'clusterName', 'generateName', 'deletionTimestamp',
// managedFields 走 METADATA_BLOCK_KEYS 整块删除(含同级数组项)// Logan.Li
]);
const METADATA_BLOCK_KEYS = new Set(['managedFields', 'ownerReferences']);
const METADATA_BLOCK_KEYS = new Set(['managedFields', 'ownerReferences', 'finalizers']);
// K8s 默认值 → 值匹配时移除
const SPEC_DEFAULTS: Record<string, string[]> = {
@@ -35,21 +35,42 @@ export function cleanYAML(yaml: string): string {
terminationGracePeriodSeconds: ['30'],
serviceAccountName: ['default'],
enableServiceLinks: ['true'],
automountServiceAccountToken: ['true'],
progressDeadlineSeconds: ['600'],
revisionHistoryLimit: ['10'],
sessionAffinity: ['None'],
externalTrafficPolicy: ['Cluster'],
internalTrafficPolicy: ['Cluster'],
ipFamilyPolicy: ['SingleStack'],
allocateLoadBalancerNodePorts: ['true'],
preemptionPolicy: ['PreemptLowerPriority'],
priority: ['0'],
};
const CONTAINER_DEFAULTS: Record<string, string[]> = {
imagePullPolicy: ['Always', 'IfNotPresent'],
terminationMessagePath: ['/dev/termination-log'],
terminationMessagePolicy: ['File'],
stdin: ['false'],
tty: ['false'],
stdinOnce: ['false'],
};
const SERVICE_RUNTIME_KEYS = new Set([
'clusterIP', 'clusterIPs', 'ipFamilies', 'healthCheckNodePort',
]);
const ALWAYS_REMOVE_ANNOTATIONS = new Set([
'kubectl.kubernetes.io/last-applied-configuration',
'kubectl.kubernetes.io/change-cause',
'deployment.kubernetes.io/revision',
'deployment.kubernetes.io/desired-replicas',
'deployment.kubernetes.io/max-replicas',
'kubectl.kubernetes.io/restartedAt',
'control-plane.alpha.kubernetes.io/leader',
'autoscaling.alpha.kubernetes.io/conditions',
'kubernetes.io/config.hash',
'pv.kubernetes.io/bind-completed',
'pv.kubernetes.io/bound-by-controller',
]);
const STRIP_LABEL_KEYS = new Set(['pod-template-hash', 'controller-revision-hash']);
const SYSTEM_VOLUME_PREFIXES = ['kube-api-access-', 'default-token-', 'kube-'];
const SYSTEM_MOUNT_PATHS = ['/var/run/secrets/kubernetes.io/serviceaccount'];
@@ -167,9 +188,10 @@ export function cleanYAML(yaml: string): string {
inAnnotations = false;
}
// labels 下的 pod-template-hash
// labels 下控制器自动标签
if (indent >= metaIndent && indent <= metaIndent + 2) {
if (trimmed.startsWith('pod-template-hash') && trimmed.includes(':')) {
const labelKey = extractKey(line);
if (labelKey && STRIP_LABEL_KEYS.has(labelKey)) {
i++;
continue;
}
@@ -230,13 +252,19 @@ export function cleanYAML(yaml: string): string {
// spec.selector — 不动(关键字段)
// spec.strategy — 不动
// spec 下的 K8s 默认值
if (indent === 2 && key && SPEC_DEFAULTS[key]) {
const afterColon = trimmed.split(':').slice(1).join(':').trim();
if (SPEC_DEFAULTS[key].includes(afterColon)) {
i++;
// spec 下的 K8s 默认值 / Service 运行时字段
if (indent === 2 && key) {
if (SERVICE_RUNTIME_KEYS.has(key)) {
if (hasInlineValue(line)) i++; else skipBlock(indent);
continue;
}
if (SPEC_DEFAULTS[key]) {
const afterColon = trimmed.split(':').slice(1).join(':').trim();
if (SPEC_DEFAULTS[key].includes(afterColon)) {
i++;
continue;
}
}
}
// spec 下的 nodeName(运行时分配)
@@ -245,6 +273,28 @@ export function cleanYAML(yaml: string): string {
continue;
}
// Deployment 默认滚动策略整块移除 // Logan.Li
if (indent === 2 && key === 'strategy') {
const blockLines: string[] = [];
let j = i;
while (j < lines.length) {
const l = lines[j];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { blockLines.push(l); j++; continue; }
if (j > i && getIndent(l) <= indent) break;
blockLines.push(l);
j++;
}
const blockText = blockLines.join('\n');
const isDefaultRolling = blockText.includes('type: RollingUpdate')
&& (blockText.includes('maxUnavailable: 25%') || blockText.includes('maxUnavailable: 0'))
&& (blockText.includes('maxSurge: 25%') || blockText.includes('maxSurge: 1'));
if (isDefaultRolling) {
i = j;
continue;
}
}
// template.spec 内部
if (indent === 2 && key === 'spec') {
// 这是 template.spec 的开始
+10
View File
@@ -23,3 +23,13 @@ export function exportAsJSON(filename: string, data: unknown[]): void {
a.click();
URL.revokeObjectURL(a.href);
}
/** 下载 YAML 文本(对齐集群资源导出单文件)// Logan.Li */
export function downloadYamlFile(filename: string, content: string): void {
const blob = new Blob([content], { type: 'application/x-yaml;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename.endsWith('.yaml') ? filename : `${filename}.yaml`;
a.click();
URL.revokeObjectURL(a.href);
}