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:
1 parent
eeba677850
commit
fd7b04b9d6
15 files changed
+646
-145
No files matched your search
@@ -11,4 +11,7 @@ export const namespaceApi = {
|
|||||||
http.post(`/kubernetes/${clusterCode}/namespaces`, { name, labels: labels || {} }),
|
http.post(`/kubernetes/${clusterCode}/namespaces`, { name, labels: labels || {} }),
|
||||||
deleteNamespace: async (clusterCode: string, namespace: string) =>
|
deleteNamespace: async (clusterCode: string, namespace: string) =>
|
||||||
http.delete(`/kubernetes/${clusterCode}/namespaces/${namespace}`),
|
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 { DeploymentLogsTab } from './deployment-detail/DeploymentLogsTab';
|
||||||
import { DeploymentHpaTab } from './deployment-detail/DeploymentHpaTab';
|
import { DeploymentHpaTab } from './deployment-detail/DeploymentHpaTab';
|
||||||
import { DeploymentPodModals } from './deployment-detail/DeploymentPodModals';
|
import { DeploymentPodModals } from './deployment-detail/DeploymentPodModals';
|
||||||
|
import { YamlViewer } from './YamlViewer';
|
||||||
|
|
||||||
export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBack }: DeploymentDetailProps) {
|
export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBack }: DeploymentDetailProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -33,6 +34,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
|||||||
const [logsViewerOpen, setLogsViewerOpen] = useState(false);
|
const [logsViewerOpen, setLogsViewerOpen] = useState(false);
|
||||||
const [fileBrowserPod, setFileBrowserPod] = useState<PodWithContainers | null>(null);
|
const [fileBrowserPod, setFileBrowserPod] = useState<PodWithContainers | null>(null);
|
||||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||||
|
const [yamlPodName, setYamlPodName] = useState<string | null>(null);
|
||||||
const [logsTabPod, setLogsTabPod] = useState<string>('');
|
const [logsTabPod, setLogsTabPod] = useState<string>('');
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@@ -187,6 +189,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
|||||||
onOpenLogsViewer={(pod) => { setLogsViewerPod(pod); setLogsViewerOpen(true); }}
|
onOpenLogsViewer={(pod) => { setLogsViewerPod(pod); setLogsViewerOpen(true); }}
|
||||||
onOpenExec={(pod) => { setExecPod(pod); setExecOpen(true); }}
|
onOpenExec={(pod) => { setExecPod(pod); setExecOpen(true); }}
|
||||||
onOpenFileBrowser={(pod) => { setFileBrowserPod(pod); setFileBrowserOpen(true); }}
|
onOpenFileBrowser={(pod) => { setFileBrowserPod(pod); setFileBrowserOpen(true); }}
|
||||||
|
onOpenYaml={setYamlPodName}
|
||||||
/>
|
/>
|
||||||
<DeploymentLogsTab
|
<DeploymentLogsTab
|
||||||
pods={pods}
|
pods={pods}
|
||||||
@@ -206,6 +209,17 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
|
|||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{yamlPodName && (
|
||||||
|
<YamlViewer
|
||||||
|
clusterCode={clusterCode}
|
||||||
|
namespace={namespace}
|
||||||
|
resource="pods"
|
||||||
|
name={yamlPodName}
|
||||||
|
readOnly
|
||||||
|
onClose={() => setYamlPodName(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DeploymentPodModals
|
<DeploymentPodModals
|
||||||
clusterCode={clusterCode}
|
clusterCode={clusterCode}
|
||||||
namespace={namespace}
|
namespace={namespace}
|
||||||
|
|||||||
@@ -363,7 +363,6 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
|||||||
namespace={namespace}
|
namespace={namespace}
|
||||||
resource="deployments"
|
resource="deployments"
|
||||||
name={deploymentName}
|
name={deploymentName}
|
||||||
cleanBeforeEdit
|
|
||||||
inline
|
inline
|
||||||
onSaved={loadData}
|
onSaved={loadData}
|
||||||
onClose={() => onModeChange('view')}
|
onClose={() => onModeChange('view')}
|
||||||
@@ -387,7 +386,6 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
|||||||
namespace={namespace}
|
namespace={namespace}
|
||||||
resource="deployments"
|
resource="deployments"
|
||||||
name={deploymentName}
|
name={deploymentName}
|
||||||
cleanBeforeEdit
|
|
||||||
onClose={() => setModal(null)}
|
onClose={() => setModal(null)}
|
||||||
onSaved={loadData}
|
onSaved={loadData}
|
||||||
/>
|
/>
|
||||||
@@ -416,6 +414,18 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
|||||||
</ModalShell>
|
</ModalShell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ============ Pod YAML(只读,精简视图同集群导出) ============ */}
|
||||||
|
{modal?.kind === 'podYaml' && (
|
||||||
|
<YamlViewer
|
||||||
|
clusterCode={clusterCode}
|
||||||
|
namespace={namespace}
|
||||||
|
resource="pods"
|
||||||
|
name={modal.pod}
|
||||||
|
readOnly
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ============ Pod 日志 ============ */}
|
{/* ============ Pod 日志 ============ */}
|
||||||
{modal?.kind === 'podLogs' && (
|
{modal?.kind === 'podLogs' && (
|
||||||
<PodLogsModal
|
<PodLogsModal
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type ResourceType =
|
|||||||
|
|
||||||
// 支持可视化编辑/新建的资源类型 // Logan.Li
|
// 支持可视化编辑/新建的资源类型 // Logan.Li
|
||||||
const VISUAL_RESOURCE_TYPES: ResourceType[] = ['services', 'ingresses', 'configmaps', 'secrets', 'pvcs'];
|
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 } }) => (
|
const VisualActions = ({ name, opts }: { name: string; opts: { onYaml?: (n: string) => void; onEdit?: (n: string) => void } }) => (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
@@ -71,10 +73,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
|
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-0.5">
|
<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"
|
||||||
<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 mr-1" />YAML</Button>
|
||||||
onClick={() => opts.onYaml?.(d.name)}><FileCode className="h-3.5 w-3.5" /></Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
),
|
),
|
||||||
@@ -318,7 +318,7 @@ export default function NamespaceResourcePage() {
|
|||||||
const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>();
|
const { clusterCode, namespace } = useParams<{ clusterCode: string; namespace: string }>();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast: _toast } = useToast();
|
const { toast } = useToast();
|
||||||
const type = useMemo<ResourceType>(() => {
|
const type = useMemo<ResourceType>(() => {
|
||||||
const segments = location.pathname.split('/');
|
const segments = location.pathname.split('/');
|
||||||
const last = segments[segments.length - 1];
|
const last = segments[segments.length - 1];
|
||||||
@@ -417,6 +417,15 @@ export default function NamespaceResourcePage() {
|
|||||||
const handleCreate = () => setVisualItem({ resource: type, name: '', isCreate: true });
|
const handleCreate = () => setVisualItem({ resource: type, name: '', isCreate: true });
|
||||||
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
|
const handleExportCSV = () => kubernetesApi.exportAsCSV(`${type}-${clusterCode}-${namespace}.csv`, filtered);
|
||||||
const handleExportJSON = () => kubernetesApi.exportAsJSON(`${type}-${clusterCode}-${namespace}.json`, 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>;
|
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}
|
name={yamlItem.name}
|
||||||
onClose={() => setYamlItem(null)}
|
onClose={() => setYamlItem(null)}
|
||||||
onSaved={loadData}
|
onSaved={loadData}
|
||||||
cleanBeforeEdit={true}
|
|
||||||
autoEdit={yamlItem.editMode === 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">
|
<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
|
<Download className="h-3 w-3 mr-1" />JSON
|
||||||
</Button>
|
</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) && (
|
{VISUAL_RESOURCE_TYPES.includes(type) && (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" className="h-9 border-slate-700 text-slate-400 text-xs" onClick={() => setShowYamlImport(true)}>
|
<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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { kubernetesApi } from '@/api';
|
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 { createEmptyManifest } from './resource-create-templates';
|
||||||
import { parseManifest, parseEditableManifest, prepareManifestForSave, prepareYamlForSave } from './k8s-manifest-utils';
|
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 {
|
interface ResourceVisualEditorProps {
|
||||||
clusterCode: string;
|
clusterCode: string;
|
||||||
@@ -33,23 +34,33 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
|
|||||||
const [rawYaml, setRawYaml] = useState('');
|
const [rawYaml, setRawYaml] = useState('');
|
||||||
const [manifest, setManifest] = useState<any>(null);
|
const [manifest, setManifest] = useState<any>(null);
|
||||||
const [mode, setMode] = useState<'visual' | 'yaml'>('visual');
|
const [mode, setMode] = useState<'visual' | 'yaml'>('visual');
|
||||||
|
const [displayYaml, setDisplayYaml] = useState('');
|
||||||
const [editYaml, setEditYaml] = useState('');
|
const [editYaml, setEditYaml] = useState('');
|
||||||
|
const [yamlEditing, setYamlEditing] = useState(false);
|
||||||
|
const [showRawYaml, setShowRawYaml] = useState(false);
|
||||||
|
const [yamlCopied, setYamlCopied] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (isCreate) {
|
if (isCreate) {
|
||||||
const empty = createEmptyManifest(resource, namespace, name || '');
|
const empty = createEmptyManifest(resource, namespace, name || '');
|
||||||
|
const yamlStr = await prepareManifestForSave(empty);
|
||||||
setManifest(empty);
|
setManifest(empty);
|
||||||
setEditYaml(JSON.stringify(empty, null, 2));
|
setDisplayYaml(yamlStr);
|
||||||
|
setEditYaml(yamlStr);
|
||||||
setRawYaml('');
|
setRawYaml('');
|
||||||
|
setYamlEditing(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
|
const text = await kubernetesApi.getResourceYaml(clusterCode, namespace, resource, name);
|
||||||
setRawYaml(text);
|
setRawYaml(text);
|
||||||
// 先解析原始 YAML,再用对象级剥离;cleanYAML 会破坏 managedFields 导致解析失败 // Logan.Li
|
// 先解析原始 YAML,再用对象级剥离;cleanYAML 会破坏 managedFields 导致解析失败 // Logan.Li
|
||||||
const { manifest: parsed, yaml: editableYaml } = await parseEditableManifest(text);
|
const { manifest: parsed, yaml: editableYaml } = await parseEditableManifest(text);
|
||||||
|
setDisplayYaml(editableYaml);
|
||||||
setEditYaml(editableYaml);
|
setEditYaml(editableYaml);
|
||||||
|
setYamlEditing(false);
|
||||||
|
setShowRawYaml(false);
|
||||||
setManifest(parsed);
|
setManifest(parsed);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
setMode('yaml');
|
setMode('yaml');
|
||||||
@@ -94,8 +105,12 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
|
|||||||
toast({ title: '创建成功', description: 'YAML 已应用' });
|
toast({ title: '创建成功', description: 'YAML 已应用' });
|
||||||
} else {
|
} else {
|
||||||
await kubernetesApi.updateResourceYaml(clusterCode, namespace, resource, name, yamlStr);
|
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?.();
|
onSaved?.();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -115,9 +130,31 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
|
|||||||
};
|
};
|
||||||
|
|
||||||
const switchToYaml = async () => {
|
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');
|
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 }> = {
|
const resourceMeta: Record<string, { title: string; icon: any; color: string }> = {
|
||||||
configmaps: { title: 'ConfigMap', icon: FileText, color: 'text-blue-400' },
|
configmaps: { title: 'ConfigMap', icon: FileText, color: 'text-blue-400' },
|
||||||
secrets: { title: 'Secret', icon: Shield, color: 'text-amber-400' },
|
secrets: { title: 'Secret', icon: Shield, color: 'text-amber-400' },
|
||||||
@@ -131,7 +168,7 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
|
|||||||
return (
|
return (
|
||||||
<ModalOverlay onClose={onClose}>
|
<ModalOverlay onClose={onClose}>
|
||||||
<div
|
<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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -161,30 +198,47 @@ export function ResourceVisualEditor({ clusterCode, namespace, resource, name, o
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* 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 ? (
|
{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>
|
<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' ? (
|
) : mode === 'yaml' ? (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||||
<CodeMirror
|
<div className="flex justify-end shrink-0">
|
||||||
value={editYaml}
|
<YamlEditorToolbar
|
||||||
height="550px"
|
editing={yamlEditing}
|
||||||
theme={oneDark}
|
saving={saving}
|
||||||
extensions={[yaml()]}
|
copied={yamlCopied}
|
||||||
onChange={(val) => setEditYaml(val)}
|
showRaw={showRawYaml}
|
||||||
basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: true, autocompletion: true }}
|
compact
|
||||||
style={{ fontSize: '12px' }}
|
readOnly={resCfg.readOnly}
|
||||||
/>
|
showRawToggle={!isCreate && !!rawYaml}
|
||||||
<div className="flex justify-end gap-2">
|
onCopy={copyYamlContent}
|
||||||
<Button variant="outline" size="sm" onClick={async () => {
|
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);
|
const { yaml: stripped } = await parseEditableManifest(rawYaml);
|
||||||
|
setDisplayYaml(stripped);
|
||||||
setEditYaml(stripped);
|
setEditYaml(stripped);
|
||||||
}}>重置</Button>
|
}}
|
||||||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700" onClick={handleSaveYaml} disabled={saving}>
|
onSave={handleSaveYaml}
|
||||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Save className="h-3.5 w-3.5 mr-1" />}
|
onToggleRaw={() => setShowRawYaml((v) => !v)}
|
||||||
保存
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<YamlCodeMirror
|
||||||
|
value={yamlEditing ? editYaml : (showRawYaml ? rawYaml : displayYaml)}
|
||||||
|
editing={yamlEditing}
|
||||||
|
fill
|
||||||
|
fontSize="12px"
|
||||||
|
onChange={(val) => yamlEditing && setEditYaml(val)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : manifest ? (
|
) : 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 { ModalOverlay } from '@/components/ui/modal-overlay';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { kubernetesApi } from '@/api';
|
import { kubernetesApi } from '@/api';
|
||||||
import CodeMirror from '@uiw/react-codemirror';
|
import { YamlCodeMirror } from './YamlCodeMirror';
|
||||||
import { yaml } from '@codemirror/lang-yaml';
|
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
|
||||||
|
|
||||||
interface YamlImportDialogProps {
|
interface YamlImportDialogProps {
|
||||||
clusterCode: string;
|
clusterCode: string;
|
||||||
@@ -39,7 +37,7 @@ export function YamlImportDialog({ clusterCode, onClose, onApplied }: YamlImport
|
|||||||
return (
|
return (
|
||||||
<ModalOverlay onClose={onClose}>
|
<ModalOverlay onClose={onClose}>
|
||||||
<div
|
<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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-4 border-b border-[var(--dg-border)]">
|
<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>
|
</div>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}><X className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden p-4">
|
||||||
<CodeMirror
|
<YamlCodeMirror
|
||||||
value={content}
|
value={content}
|
||||||
height="420px"
|
fill
|
||||||
theme={oneDark}
|
fontSize="12px"
|
||||||
extensions={[yaml()]}
|
|
||||||
onChange={setContent}
|
onChange={setContent}
|
||||||
placeholder="粘贴 YAML 内容..."
|
|
||||||
basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: true }}
|
|
||||||
style={{ fontSize: '12px' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 variant="outline" onClick={onClose}>取消</Button>
|
||||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={apply} disabled={applying}>
|
<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" />}
|
{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
|
// YamlViewer — YAML 查看/编辑(默认精简视图,工具栏:复制 / 编辑 / 保存)// Logan.Li
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { X, Copy, Check, Save, Loader2, Pencil } from 'lucide-react';
|
import { X, Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ModalOverlay } from '@/components/ui/modal-overlay';
|
import { ModalOverlay } from '@/components/ui/modal-overlay';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { kubernetesApi } from '@/api';
|
import { kubernetesApi } from '@/api';
|
||||||
import { parseEditableManifest, prepareYamlForSave } from './k8s-manifest-utils';
|
import { parseEditableManifest, prepareYamlForSave } from './k8s-manifest-utils';
|
||||||
import CodeMirror from '@uiw/react-codemirror';
|
import { getYamlResourceConfig, yamlDownloadFilename } from './k8s-yaml-resource-config';
|
||||||
import { yaml } from '@codemirror/lang-yaml';
|
import { YamlEditorToolbar } from './YamlEditorToolbar';
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { YamlCodeMirror } from './YamlCodeMirror';
|
||||||
import { EditorView } from '@codemirror/view';
|
import { downloadYamlFile } from '@/utils/export';
|
||||||
|
|
||||||
interface YamlViewerProps {
|
interface YamlViewerProps {
|
||||||
clusterCode: string;
|
clusterCode: string;
|
||||||
@@ -18,58 +18,26 @@ interface YamlViewerProps {
|
|||||||
name: string;
|
name: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved?: () => void;
|
onSaved?: () => void;
|
||||||
/** @deprecated 始终默认精简,保留兼容 */
|
|
||||||
cleanBeforeEdit?: boolean;
|
|
||||||
autoEdit?: boolean;
|
autoEdit?: boolean;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
initialYaml?: string;
|
initialYaml?: string;
|
||||||
}
|
/** 覆盖资源配置的只读行为(Pod 默认只读)// Logan.Li */
|
||||||
|
readOnly?: boolean;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function YamlViewer({
|
export function YamlViewer({
|
||||||
clusterCode, namespace, resource, name, onClose, onSaved,
|
clusterCode, namespace, resource, name, onClose, onSaved,
|
||||||
autoEdit = false, inline = false, initialYaml,
|
autoEdit = false, inline = false, initialYaml, readOnly: readOnlyProp,
|
||||||
}: YamlViewerProps) {
|
}: YamlViewerProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const resCfg = getYamlResourceConfig(resource);
|
||||||
|
const readOnly = readOnlyProp ?? resCfg.readOnly;
|
||||||
const [rawYaml, setRawYaml] = useState(initialYaml || '');
|
const [rawYaml, setRawYaml] = useState(initialYaml || '');
|
||||||
const [displayYaml, setDisplayYaml] = useState('');
|
const [displayYaml, setDisplayYaml] = useState('');
|
||||||
const [editYaml, setEditYaml] = useState('');
|
const [editYaml, setEditYaml] = useState('');
|
||||||
const [loading, setLoading] = useState(!initialYaml);
|
const [loading, setLoading] = useState(!initialYaml);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [editing, setEditing] = useState(autoEdit);
|
const [editing, setEditing] = useState(autoEdit && !readOnly);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [showRaw, setShowRaw] = useState(false);
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
|
|
||||||
@@ -130,29 +98,31 @@ export function YamlViewer({
|
|||||||
} finally { setSaving(false); }
|
} finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentYaml = () => (editing ? editYaml : (showRaw ? rawYaml : displayYaml));
|
||||||
|
|
||||||
const copyYaml = async () => {
|
const copyYaml = async () => {
|
||||||
const content = editing ? editYaml : (showRaw ? rawYaml : displayYaml);
|
await navigator.clipboard.writeText(currentYaml());
|
||||||
await navigator.clipboard.writeText(content);
|
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
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 display = editing ? editYaml : (showRaw ? rawYaml : displayYaml);
|
||||||
|
|
||||||
const extensions = useMemo(() => [
|
|
||||||
yaml(),
|
|
||||||
EditorView.lineWrapping,
|
|
||||||
...(editing ? [] : [EditorView.editable.of(false)]),
|
|
||||||
], [editing]);
|
|
||||||
|
|
||||||
const toolbar = (
|
const toolbar = (
|
||||||
<YamlToolbar
|
<YamlEditorToolbar
|
||||||
editing={editing}
|
editing={editing}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
copied={copied}
|
copied={copied}
|
||||||
showRaw={showRaw}
|
showRaw={showRaw}
|
||||||
inline={inline}
|
compact={inline}
|
||||||
|
readOnly={readOnly}
|
||||||
onCopy={copyYaml}
|
onCopy={copyYaml}
|
||||||
|
onDownload={downloadYaml}
|
||||||
onEdit={enterEdit}
|
onEdit={enterEdit}
|
||||||
onCancel={cancelEdit}
|
onCancel={cancelEdit}
|
||||||
onSave={saveYaml}
|
onSave={saveYaml}
|
||||||
@@ -161,40 +131,29 @@ export function YamlViewer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const editor = loading ? (
|
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" />
|
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<CodeMirror
|
<YamlCodeMirror
|
||||||
value={display}
|
value={display}
|
||||||
height={inline ? '600px' : '100%'}
|
editing={editing}
|
||||||
theme={oneDark}
|
fill
|
||||||
extensions={extensions}
|
fontSize={inline ? '12px' : '13px'}
|
||||||
onChange={(val) => editing && setEditYaml(val)}
|
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) {
|
if (inline) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="flex items-center justify-between gap-3 flex-wrap shrink-0">
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
<code className="text-slate-400">{resource}/{name}</code> · {namespace}
|
<code className="text-slate-400">{resource}/{name}</code> · {namespace}
|
||||||
</p>
|
</p>
|
||||||
{toolbar}
|
{toolbar}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,12 +161,15 @@ export function YamlViewer({
|
|||||||
return (
|
return (
|
||||||
<ModalOverlay onClose={onClose}>
|
<ModalOverlay onClose={onClose}>
|
||||||
<div
|
<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()}
|
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">
|
<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>
|
<p className="text-xs text-slate-500">{namespace} · {clusterCode}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<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>
|
<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>
|
</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>
|
</div>
|
||||||
</ModalOverlay>
|
</ModalOverlay>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// DeploymentPodsTab.tsx — Deployment 详情 Pod 列表 Tab // Logan.Li
|
// 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 { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -15,9 +15,10 @@ export interface DeploymentPodsTabProps {
|
|||||||
onOpenLogsViewer: (pod: PodWithContainers) => void;
|
onOpenLogsViewer: (pod: PodWithContainers) => void;
|
||||||
onOpenExec: (pod: PodWithContainers) => void;
|
onOpenExec: (pod: PodWithContainers) => void;
|
||||||
onOpenFileBrowser: (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 (
|
return (
|
||||||
<TabsContent value="pods">
|
<TabsContent value="pods">
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
<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) })}>
|
onClick={() => onOpenFileBrowser({ name: pod.name, containers: extractContainers(pod) })}>
|
||||||
<FolderOpen className="h-3.5 w-3.5" />
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
ChevronDown, ChevronRight, Terminal, FileText, HardDrive,
|
ChevronDown, ChevronRight, Terminal, FileText, HardDrive, FileCode,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { formatK8sTime, statusColor } from '../k8s-yaml-utils';
|
import { formatK8sTime, statusColor } from '../k8s-yaml-utils';
|
||||||
import { getPodTimeline, PodConditionsRow } from './helpers';
|
import { getPodTimeline, PodConditionsRow } from './helpers';
|
||||||
import type { DeploymentViewProps } from './types';
|
import type { DeploymentViewProps } from './types';
|
||||||
|
|
||||||
export function PodsTab({
|
export function PodsTab({
|
||||||
clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodFileBrowser,
|
clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser,
|
||||||
expandedPod, setExpandedPod,
|
expandedPod, setExpandedPod,
|
||||||
}: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
|
}: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
|
||||||
if (pods.length === 0) {
|
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)}>
|
<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" />文件
|
<HardDrive className="h-3.5 w-3.5 mr-1" />文件
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
{/* Expanded: timeline */}
|
{/* Expanded: timeline */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import { cleanYAML } from './k8s-yaml-utils';
|
|||||||
/** 主流方案:编辑/展示时应排除的 metadata 注解 // Logan.Li */
|
/** 主流方案:编辑/展示时应排除的 metadata 注解 // Logan.Li */
|
||||||
const STRIP_ANNOTATIONS = new Set([
|
const STRIP_ANNOTATIONS = new Set([
|
||||||
'kubectl.kubernetes.io/last-applied-configuration',
|
'kubectl.kubernetes.io/last-applied-configuration',
|
||||||
'deployment.kubernetes.io/revision',
|
'kubectl.kubernetes.io/change-cause',
|
||||||
'kubectl.kubernetes.io/restartedAt',
|
'kubectl.kubernetes.io/restartedAt',
|
||||||
|
'deployment.kubernetes.io/revision',
|
||||||
|
'deployment.kubernetes.io/desired-replicas',
|
||||||
|
'deployment.kubernetes.io/max-replicas',
|
||||||
'control-plane.alpha.kubernetes.io/leader',
|
'control-plane.alpha.kubernetes.io/leader',
|
||||||
'autoscaling.alpha.kubernetes.io/conditions',
|
'autoscaling.alpha.kubernetes.io/conditions',
|
||||||
'kubernetes.io/config.hash',
|
'kubernetes.io/config.hash',
|
||||||
'kubeadm.kubernetes.io/etcd.advertise-client-urls',
|
'kubeadm.kubernetes.io/etcd.advertise-client-urls',
|
||||||
|
'pv.kubernetes.io/bind-completed',
|
||||||
|
'pv.kubernetes.io/bound-by-controller',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** metadata 运行时字段(kubectl/Kuboard/Lens 等均不展示)// Logan.Li */
|
/** metadata 运行时字段(kubectl/Kuboard/Lens 等均不展示)// Logan.Li */
|
||||||
@@ -18,6 +23,74 @@ const STRIP_METADATA_KEYS = [
|
|||||||
'selfLink', 'deletionTimestamp', 'ownerReferences', 'finalizers', 'clusterName', 'generateName',
|
'selfLink', 'deletionTimestamp', 'ownerReferences', 'finalizers', 'clusterName', 'generateName',
|
||||||
] as const;
|
] 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 {
|
function stripMetadataBlock(meta: Record<string, any> | undefined): void {
|
||||||
if (!meta || typeof meta !== 'object') return;
|
if (!meta || typeof meta !== 'object') return;
|
||||||
for (const k of STRIP_METADATA_KEYS) delete meta[k];
|
for (const k of STRIP_METADATA_KEYS) delete meta[k];
|
||||||
@@ -28,29 +101,151 @@ function stripMetadataBlock(meta: Record<string, any> | undefined): void {
|
|||||||
if (Object.keys(meta.annotations).length === 0) delete meta.annotations;
|
if (Object.keys(meta.annotations).length === 0) delete meta.annotations;
|
||||||
}
|
}
|
||||||
if (meta.labels) {
|
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;
|
if (Object.keys(meta.labels).length === 0) delete meta.labels;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 递归移除空对象(如 securityContext: {})// Logan.Li */
|
/** 递归移除空对象/空数组 // Logan.Li */
|
||||||
function pruneEmptyObjects(node: unknown): void {
|
function pruneEmpty(node: unknown): void {
|
||||||
if (!node || typeof node !== 'object') return;
|
if (!node || typeof node !== 'object') return;
|
||||||
if (Array.isArray(node)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const obj = node as Record<string, unknown>;
|
const obj = node as Record<string, unknown>;
|
||||||
for (const key of Object.keys(obj)) {
|
for (const key of Object.keys(obj)) {
|
||||||
const val = obj[key];
|
const val = obj[key];
|
||||||
if (val && typeof val === 'object') {
|
if (val && typeof val === 'object') pruneEmpty(val);
|
||||||
if (Array.isArray(val)) val.forEach(pruneEmptyObjects);
|
if (isEmptyCollection(val)) delete obj[key];
|
||||||
else pruneEmptyObjects(val);
|
}
|
||||||
if (!Array.isArray(val) && typeof val === 'object' && Object.keys(val as object).length === 0) {
|
}
|
||||||
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 */
|
/** 对象级剥离运行时字段 // Logan.Li */
|
||||||
@@ -60,8 +255,8 @@ export function stripRuntimeFields(manifest: Record<string, any>): Record<string
|
|||||||
stripMetadataBlock(cloned.metadata);
|
stripMetadataBlock(cloned.metadata);
|
||||||
const tpl = cloned.spec?.template;
|
const tpl = cloned.spec?.template;
|
||||||
if (tpl?.metadata) stripMetadataBlock(tpl.metadata);
|
if (tpl?.metadata) stripMetadataBlock(tpl.metadata);
|
||||||
if (cloned.spec?.nodeName) delete cloned.spec.nodeName;
|
stripKindDefaults(cloned);
|
||||||
pruneEmptyObjects(cloned);
|
pruneEmpty(cloned);
|
||||||
return 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([
|
const METADATA_LINE_KEYS = new Set([
|
||||||
'uid', 'resourceVersion', 'creationTimestamp', 'generation',
|
'uid', 'resourceVersion', 'creationTimestamp', 'generation',
|
||||||
'selfLink', 'clusterName', 'generateName',
|
'selfLink', 'clusterName', 'generateName', 'deletionTimestamp',
|
||||||
// managedFields 走 METADATA_BLOCK_KEYS 整块删除(含同级数组项)// Logan.Li
|
// managedFields 走 METADATA_BLOCK_KEYS 整块删除(含同级数组项)// Logan.Li
|
||||||
]);
|
]);
|
||||||
const METADATA_BLOCK_KEYS = new Set(['managedFields', 'ownerReferences']);
|
const METADATA_BLOCK_KEYS = new Set(['managedFields', 'ownerReferences', 'finalizers']);
|
||||||
|
|
||||||
// K8s 默认值 → 值匹配时移除
|
// K8s 默认值 → 值匹配时移除
|
||||||
const SPEC_DEFAULTS: Record<string, string[]> = {
|
const SPEC_DEFAULTS: Record<string, string[]> = {
|
||||||
@@ -35,21 +35,42 @@ export function cleanYAML(yaml: string): string {
|
|||||||
terminationGracePeriodSeconds: ['30'],
|
terminationGracePeriodSeconds: ['30'],
|
||||||
serviceAccountName: ['default'],
|
serviceAccountName: ['default'],
|
||||||
enableServiceLinks: ['true'],
|
enableServiceLinks: ['true'],
|
||||||
|
automountServiceAccountToken: ['true'],
|
||||||
progressDeadlineSeconds: ['600'],
|
progressDeadlineSeconds: ['600'],
|
||||||
revisionHistoryLimit: ['10'],
|
revisionHistoryLimit: ['10'],
|
||||||
|
sessionAffinity: ['None'],
|
||||||
|
externalTrafficPolicy: ['Cluster'],
|
||||||
|
internalTrafficPolicy: ['Cluster'],
|
||||||
|
ipFamilyPolicy: ['SingleStack'],
|
||||||
|
allocateLoadBalancerNodePorts: ['true'],
|
||||||
|
preemptionPolicy: ['PreemptLowerPriority'],
|
||||||
|
priority: ['0'],
|
||||||
};
|
};
|
||||||
const CONTAINER_DEFAULTS: Record<string, string[]> = {
|
const CONTAINER_DEFAULTS: Record<string, string[]> = {
|
||||||
imagePullPolicy: ['Always', 'IfNotPresent'],
|
imagePullPolicy: ['Always', 'IfNotPresent'],
|
||||||
terminationMessagePath: ['/dev/termination-log'],
|
terminationMessagePath: ['/dev/termination-log'],
|
||||||
terminationMessagePolicy: ['File'],
|
terminationMessagePolicy: ['File'],
|
||||||
|
stdin: ['false'],
|
||||||
|
tty: ['false'],
|
||||||
|
stdinOnce: ['false'],
|
||||||
};
|
};
|
||||||
|
const SERVICE_RUNTIME_KEYS = new Set([
|
||||||
|
'clusterIP', 'clusterIPs', 'ipFamilies', 'healthCheckNodePort',
|
||||||
|
]);
|
||||||
const ALWAYS_REMOVE_ANNOTATIONS = new Set([
|
const ALWAYS_REMOVE_ANNOTATIONS = new Set([
|
||||||
'kubectl.kubernetes.io/last-applied-configuration',
|
'kubectl.kubernetes.io/last-applied-configuration',
|
||||||
|
'kubectl.kubernetes.io/change-cause',
|
||||||
'deployment.kubernetes.io/revision',
|
'deployment.kubernetes.io/revision',
|
||||||
|
'deployment.kubernetes.io/desired-replicas',
|
||||||
|
'deployment.kubernetes.io/max-replicas',
|
||||||
'kubectl.kubernetes.io/restartedAt',
|
'kubectl.kubernetes.io/restartedAt',
|
||||||
'control-plane.alpha.kubernetes.io/leader',
|
'control-plane.alpha.kubernetes.io/leader',
|
||||||
'autoscaling.alpha.kubernetes.io/conditions',
|
'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_VOLUME_PREFIXES = ['kube-api-access-', 'default-token-', 'kube-'];
|
||||||
const SYSTEM_MOUNT_PATHS = ['/var/run/secrets/kubernetes.io/serviceaccount'];
|
const SYSTEM_MOUNT_PATHS = ['/var/run/secrets/kubernetes.io/serviceaccount'];
|
||||||
|
|
||||||
@@ -167,9 +188,10 @@ export function cleanYAML(yaml: string): string {
|
|||||||
inAnnotations = false;
|
inAnnotations = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// labels 下的 pod-template-hash
|
// labels 下控制器自动标签
|
||||||
if (indent >= metaIndent && indent <= metaIndent + 2) {
|
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++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -230,14 +252,20 @@ export function cleanYAML(yaml: string): string {
|
|||||||
// spec.selector — 不动(关键字段)
|
// spec.selector — 不动(关键字段)
|
||||||
// spec.strategy — 不动
|
// spec.strategy — 不动
|
||||||
|
|
||||||
// spec 下的 K8s 默认值
|
// spec 下的 K8s 默认值 / Service 运行时字段
|
||||||
if (indent === 2 && key && SPEC_DEFAULTS[key]) {
|
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();
|
const afterColon = trimmed.split(':').slice(1).join(':').trim();
|
||||||
if (SPEC_DEFAULTS[key].includes(afterColon)) {
|
if (SPEC_DEFAULTS[key].includes(afterColon)) {
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// spec 下的 nodeName(运行时分配)
|
// spec 下的 nodeName(运行时分配)
|
||||||
if (indent === 2 && key === 'nodeName') {
|
if (indent === 2 && key === 'nodeName') {
|
||||||
@@ -245,6 +273,28 @@ export function cleanYAML(yaml: string): string {
|
|||||||
continue;
|
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 内部
|
// template.spec 内部
|
||||||
if (indent === 2 && key === 'spec') {
|
if (indent === 2 && key === 'spec') {
|
||||||
// 这是 template.spec 的开始
|
// 这是 template.spec 的开始
|
||||||
|
|||||||
@@ -23,3 +23,13 @@ export function exportAsJSON(filename: string, data: unknown[]): void {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(a.href);
|
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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user