From fd7b04b9d6894e2615dede45302a3fd9aef075d5 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Fri, 26 Jun 2026 15:25:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(k8s):=20=E7=BB=9F=E4=B8=80=20YAML=20?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E7=BC=96=E8=BE=91=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=20Pod/=E5=AF=BC=E5=87=BA=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽取 YamlCodeMirror/YamlEditorToolbar,修复弹窗滚动与固定高度布局 - 对齐集群导出精简规则(Service/Ingress/Workload/PVC 注解等) - Deployment/Pod/Service/Ingress 分资源配置:Pod 只读,支持单文件下载 - 修复 Deployment Pod YAML 弹窗未渲染,补全 PodsTab 与详情页 YAML 入口 - 命名空间列表页新增导出 YAML(tar.gz),与集群导出 API 一致 --- frontend/src/api/kubernetes/namespace.ts | 3 + .../kubernetes/DeploymentDetail.tsx | 14 ++ .../kubernetes/DeploymentWorkload.tsx | 14 +- .../kubernetes/NamespaceResourcePage.tsx | 25 +- .../kubernetes/ResourceVisualEditor.tsx | 108 ++++++--- .../components/kubernetes/YamlCodeMirror.tsx | 90 +++++++ .../kubernetes/YamlEditorToolbar.tsx | 64 +++++ .../kubernetes/YamlImportDialog.tsx | 20 +- .../src/components/kubernetes/YamlViewer.tsx | 116 ++++------ .../deployment-detail/DeploymentPodsTab.tsx | 10 +- .../kubernetes/deployment-view/PodsTab.tsx | 7 +- .../kubernetes/k8s-manifest-utils.ts | 219 +++++++++++++++++- .../kubernetes/k8s-yaml-resource-config.ts | 33 +++ .../components/kubernetes/k8s-yaml-utils.ts | 68 +++++- frontend/src/utils/export.ts | 10 + 15 files changed, 651 insertions(+), 150 deletions(-) create mode 100644 frontend/src/components/kubernetes/YamlCodeMirror.tsx create mode 100644 frontend/src/components/kubernetes/YamlEditorToolbar.tsx create mode 100644 frontend/src/components/kubernetes/k8s-yaml-resource-config.ts diff --git a/frontend/src/api/kubernetes/namespace.ts b/frontend/src/api/kubernetes/namespace.ts index 41393ec..d05c27b 100644 --- a/frontend/src/api/kubernetes/namespace.ts +++ b/frontend/src/api/kubernetes/namespace.ts @@ -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`), }; \ No newline at end of file diff --git a/frontend/src/components/kubernetes/DeploymentDetail.tsx b/frontend/src/components/kubernetes/DeploymentDetail.tsx index 723332d..2502db8 100644 --- a/frontend/src/components/kubernetes/DeploymentDetail.tsx +++ b/frontend/src/components/kubernetes/DeploymentDetail.tsx @@ -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(null); const [fileBrowserOpen, setFileBrowserOpen] = useState(false); + const [yamlPodName, setYamlPodName] = useState(null); const [logsTabPod, setLogsTabPod] = useState(''); 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} /> + {yamlPodName && ( + setYamlPodName(null)} + /> + )} + 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 )} + {/* ============ Pod YAML(只读,精简视图同集群导出) ============ */} + {modal?.kind === 'podYaml' && ( + setModal(null)} + /> + )} + {/* ============ Pod 日志 ============ */} {modal?.kind === 'podLogs' && ( void; onEdit?: (n: string) => void } }) => (
@@ -71,10 +73,8 @@ const RESOURCE_CONFIG: Record {d.image || '-'} e.stopPropagation()}> -
- -
+
), @@ -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(() => { 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
未知资源类型: {type}
; @@ -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() { + {EXPORT_YAML_TYPES.includes(type) && ( + + )} {VISUAL_RESOURCE_TYPES.includes(type) && ( <> - +
+
+ { 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)} + />
+ yamlEditing && setEditYaml(val)} + />
) : manifest ? ( <> diff --git a/frontend/src/components/kubernetes/YamlCodeMirror.tsx b/frontend/src/components/kubernetes/YamlCodeMirror.tsx new file mode 100644 index 0000000..90808bc --- /dev/null +++ b/frontend/src/components/kubernetes/YamlCodeMirror.tsx @@ -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(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 = ( + + ); + + if (fill) { + return ( +
+ {editor} +
+ ); + } + + return ( +
+ {editor} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/YamlEditorToolbar.tsx b/frontend/src/components/kubernetes/YamlEditorToolbar.tsx new file mode 100644 index 0000000..e8e3b3c --- /dev/null +++ b/frontend/src/components/kubernetes/YamlEditorToolbar.tsx @@ -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 ( +
+ + {onDownload && ( + + )} + {!readOnly && ( + !editing ? ( + + ) : ( + <> + + + + ) + )} + {showRawToggle && onToggleRaw && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/YamlImportDialog.tsx b/frontend/src/components/kubernetes/YamlImportDialog.tsx index 60a5d84..2ae799a 100644 --- a/frontend/src/components/kubernetes/YamlImportDialog.tsx +++ b/frontend/src/components/kubernetes/YamlImportDialog.tsx @@ -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 (
e.stopPropagation()} >
@@ -52,19 +50,15 @@ export function YamlImportDialog({ clusterCode, onClose, onApplied }: YamlImport
-
- +
-
+
- {!editing ? ( - - ) : ( - <> - - - - )} - -
- ); + /** 覆盖资源配置的只读行为(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 = ( - +
) : ( - 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 ( -
-
+
+

{resource}/{name} · {namespace}

{toolbar}
-
{editor}
+
{editor}
); } @@ -202,12 +161,15 @@ export function YamlViewer({ return (
e.stopPropagation()} > -
+
-

YAML — {resource}/{name}

+

+ {resCfg.label} YAML — {name} + {readOnly && 只读} +

{namespace} · {clusterCode}

@@ -215,7 +177,7 @@ export function YamlViewer({
-
{editor}
+
{editor}
); diff --git a/frontend/src/components/kubernetes/deployment-detail/DeploymentPodsTab.tsx b/frontend/src/components/kubernetes/deployment-detail/DeploymentPodsTab.tsx index 816e246..7b3cdc5 100644 --- a/frontend/src/components/kubernetes/deployment-detail/DeploymentPodsTab.tsx +++ b/frontend/src/components/kubernetes/deployment-detail/DeploymentPodsTab.tsx @@ -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 ( @@ -73,6 +74,11 @@ export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFi onClick={() => onOpenFileBrowser({ name: pod.name, containers: extractContainers(pod) })}> +
diff --git a/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx b/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx index 36d7533..9d7db97 100644 --- a/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx +++ b/frontend/src/components/kubernetes/deployment-view/PodsTab.tsx @@ -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({ +
{/* Expanded: timeline */} {expanded && ( diff --git a/frontend/src/components/kubernetes/k8s-manifest-utils.ts b/frontend/src/components/kubernetes/k8s-manifest-utils.ts index e38f236..2c534c3 100644 --- a/frontend/src/components/kubernetes/k8s-manifest-utils.ts +++ b/frontend/src/components/kubernetes/k8s-manifest-utils.ts @@ -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 = { + dnsPolicy: 'ClusterFirst', + restartPolicy: 'Always', + schedulerName: 'default-scheduler', + terminationGracePeriodSeconds: 30, + enableServiceLinks: true, + serviceAccountName: 'default', + automountServiceAccountToken: true, + priority: 0, + preemptionPolicy: 'PreemptLowerPriority', +}; + +const DEPLOYMENT_SPEC_DEFAULTS: Record = { + progressDeadlineSeconds: 600, + revisionHistoryLimit: 10, +}; + +const CONTAINER_DEFAULTS: Record = { + terminationMessagePath: '/dev/termination-log', + terminationMessagePolicy: 'File', + stdin: false, + tty: false, + stdinOnce: false, +}; + +const SERVICE_SPEC_DEFAULTS: Record = { + 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 | 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 | 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; 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 | undefined, defaults: Record): 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 | 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> | 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): 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 | 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 | 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 | 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 | undefined): void { + if (!spec || typeof spec !== 'object') return; + if (!spec.ingressClassName) delete spec.ingressClassName; +} + +function stripWorkloadTemplate(spec: Record | 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): 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): Record { const cloned = JSON.parse(JSON.stringify(manifest)); @@ -60,8 +255,8 @@ export function stripRuntimeFields(manifest: Record): Record = { + 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`; +} \ No newline at end of file diff --git a/frontend/src/components/kubernetes/k8s-yaml-utils.ts b/frontend/src/components/kubernetes/k8s-yaml-utils.ts index ee63ece..98a4865 100644 --- a/frontend/src/components/kubernetes/k8s-yaml-utils.ts +++ b/frontend/src/components/kubernetes/k8s-yaml-utils.ts @@ -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 = { @@ -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 = { 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 的开始 diff --git a/frontend/src/utils/export.ts b/frontend/src/utils/export.ts index 9821e8a..9119c4a 100644 --- a/frontend/src/utils/export.ts +++ b/frontend/src/utils/export.ts @@ -22,4 +22,14 @@ export function exportAsJSON(filename: string, data: unknown[]): void { a.download = filename; 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); } \ No newline at end of file