feat(k8s): M8+M9 存储挂载/高级设置/服务路由 Tab — Deployment 可视化编辑器 5 Tab 全部完成

M8 存储挂载 Tab (DeploymentEditStorage.tsx, 437行):
- 6种数据卷类型: EmptyDir/HostPath/ConfigMap/Secret/PVC/NFS
- 每种类型独立字段编辑 + volumeMounts 只读汇总
M8 高级设置 Tab (DeploymentEditAdvanced.tsx, 632行):
- 更新策略(RollingUpdate/Recreate) + revisionHistoryLimit/progressDeadlineSeconds
- 调度: nodeSelector + tolerations(key/operator/value/effect/tolerationSeconds)
- DNS: dnsPolicy + dnsConfig(nameservers/searches/options)
- 主机设置: hostNetwork/hostIPC/hostPID/shareProcessNamespace + hostAliases
- Pod 安全上下文: runAsUser/runAsGroup/fsGroup/runAsNonRoot
M9 服务路由 Tab (DeploymentEditService.tsx):
- selector 双路径关联 Service(spec.selector.matchLabels || selector)
- Service/Ingress 创建/删除集成

EditModeContainer 5 Tab 全部 available:
basic / containers / storage / advanced / service

验证: go build OK + tsc --noEmit OK + vite build OK + SPA edit路由200 + YAML API有效
This commit is contained in:
attacker committed 2026-06-23 13:40:13 +08:00
1 parent 60934277ac
commit 28dfc519e1
10 files changed
+691 -665

No files matched your search

@@ -571,7 +571,6 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
if (!o) setExecPod(null);
}}
clusterId={clusterCode}
clusterCode={clusterCode}
namespace={namespace}
podName={execPod.name}
containers={execPod.containers}
@@ -11,6 +11,7 @@ import {
import {
Clock, Cpu, MemoryStick, HardDrive, Network, Activity,
RotateCcw, FileCode, Server, Container, History, ChevronDown, ChevronRight,
Terminal, FileText,
} from 'lucide-react';
import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
@@ -33,6 +34,7 @@ interface DeploymentViewProps {
onShowPodLogs: (pod: PodItem) => void;
onShowPodTerminal: (pod: PodItem) => void;
onShowPodYaml: (pod: PodItem) => void;
onShowPodFileBrowser: (pod: PodItem) => void;
}
/** 计算 Pod 从调度到就绪的各阶段耗时(基于条件时间线,兼容扁平字段) */
@@ -67,7 +69,7 @@ function StatCard({ label, value, sub, color }: { label: string; value: React.Re
export function DeploymentView({
clusterCode, namespace, deployment, pods, replicaSets, revisions,
onRefresh, onShowPodLogs, onShowPodTerminal, onShowPodYaml,
onRefresh, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser,
}: DeploymentViewProps) {
const navigate = useNavigate();
const { toast } = useToast();
@@ -177,10 +179,16 @@ export function DeploymentView({
<TableCell className="text-xs text-slate-400">{formatDuration(schedToReady)}</TableCell>
<TableCell className="text-xs text-slate-500">{formatK8sTime(tl.startTime || pod.created_at)}</TableCell>
<TableCell>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="日志" onClick={() => onShowPodLogs(pod)}><FileCode className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-emerald-400" title="终端" onClick={() => onShowPodTerminal(pod)}><Container className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-amber-400" title="YAML" onClick={() => onShowPodYaml(pod)}><FileCode className="h-3.5 w-3.5" /></Button>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" onClick={() => onShowPodLogs(pod)}>
<FileText className="h-3.5 w-3.5 mr-1" />追踪日志
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10" onClick={() => onShowPodTerminal(pod)}>
<Terminal className="h-3.5 w-3.5 mr-1" />终端
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10" onClick={() => onShowPodFileBrowser(pod)}>
<HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器
</Button>
</div>
</TableCell>
</TableRow>
@@ -29,6 +29,8 @@ import { DeploymentEditBasic } from './DeploymentEditBasic';
import { DeploymentEditStorage } from './DeploymentEditStorage';
import { DeploymentEditAdvanced } from './DeploymentEditAdvanced';
import { DeploymentEditService } from './DeploymentEditService';
import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser';
type WorkloadMode = 'view' | 'edit' | 'yaml';
@@ -67,6 +69,9 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
const [newImage, setNewImage] = useState('');
// 伸缩
const [scaleValue, setScaleValue] = useState(0);
const [terminalPod, setTerminalPod] = useState<string | null>(null);
const [fileBrowserPod, setFileBrowserPod] = useState<string | null>(null);
const clusterId = deployment?.cluster_id || deployment?.clusterId || clusterCode;
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
@@ -226,8 +231,11 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
// 日志流(Pod 实时日志)
const handleShowPodLogs = (pod: any) => setModal({ kind: 'podLogs', pod: pod.name });
const handleShowPodYaml = (pod: any) => setModal({ kind: 'podYaml', pod: pod.name });
const handleShowPodTerminal = (_pod: any) => {
toast({ title: '容器终端', description: 'Pod exec 终端复用 KubectlTerminal 通道,开发中' });
const handleShowPodTerminal = (pod: any) => {
setTerminalPod(pod.name);
};
const handleShowPodFileBrowser = (pod: any) => {
setFileBrowserPod(pod.name);
};
// ============ 渲染 ============
@@ -397,6 +405,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
onShowPodLogs={handleShowPodLogs}
onShowPodTerminal={handleShowPodTerminal}
onShowPodYaml={handleShowPodYaml}
onShowPodFileBrowser={handleShowPodFileBrowser}
/>
) : mode === 'yaml' ? (
/* YAML 编辑模式 — 内联 CodeMirror 编辑器 */
@@ -435,18 +444,6 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
/>
)}
{/* 弹窗:Pod YAML */}
{modal?.kind === 'podYaml' && (
<YamlViewer
clusterCode={clusterCode}
namespace={namespace}
resource="pods"
name={modal.pod}
cleanBeforeEdit
onClose={() => setModal(null)}
/>
)}
{/* ============ 弹窗:调整镜像版本 ============ */}
{modal?.kind === 'image' && (
<ModalShell title="调整镜像版本" onClose={() => setModal(null)}>
@@ -524,7 +521,7 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
</ModalShell>
)}
{/* ============ 弹窗:Pod 日志 ============ */}
{/* ============ Pod 日志 ============ */}
{modal?.kind === 'podLogs' && (
<PodLogsModal
clusterCode={clusterCode}
@@ -534,6 +531,25 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
onClose={() => setModal(null)}
/>
)}
{/* ============ Pod 终端 ============ */}
<PodExecTerminal
open={!!terminalPod}
onOpenChange={(open) => { if (!open) setTerminalPod(null); }}
clusterId={clusterId}
namespace={namespace}
podName={terminalPod || ''}
containers={(pods.find((p: any) => p.name === terminalPod)?.containers || []).map((c: any) => c.name).filter(Boolean)}
/>
{/* ============ Pod 文件浏览器 ============ */}
<PodFileBrowser
open={!!fileBrowserPod}
onOpenChange={(open) => { if (!open) setFileBrowserPod(null); }}
clusterCode={clusterCode}
namespace={namespace}
podName={fileBrowserPod || ''}
containers={(pods.find((p: any) => p.name === fileBrowserPod)?.containers || []).map((c: any) => c.name).filter(Boolean)}
/>
</div>
);
}
@@ -13,9 +13,11 @@ import {
import {
RefreshCw, Search, Loader2, Boxes, Container,
Network, FileText, Shield, Activity, Clock, ChevronRight,
Download, FileCode, Pencil,
Download, FileCode, Pencil, Terminal, HardDrive,
} from 'lucide-react';
import { YamlViewer } from './YamlViewer';
import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser';
import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
@@ -29,7 +31,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
subtitle: string;
icon: React.ComponentType<any>;
columns: string[];
renderRow: (item: any, opts: { clusterCode: string; namespace: string; onDetail?: (item: any) => void; onYaml?: (name: string) => void; onEdit?: (name: string) => void }) => React.ReactNode;
renderRow: (item: any, opts: { clusterCode: string; namespace: string; onDetail?: (item: any) => void; onYaml?: (name: string) => void; onEdit?: (name: string) => void; onTerminal?: (item: any) => void; onFileBrowser?: (item: any) => void }) => React.ReactNode;
}> = {
deployments: {
title: 'Deployments', subtitle: 'Deployment 工作负载运行状态', icon: Boxes,
@@ -77,7 +79,16 @@ const RESOURCE_CONFIG: Record<ResourceType, {
<TableCell className="text-sm text-amber-400">{p.mem_usage_mi || '-'}</TableCell>
<TableCell className={(p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500')}>{p.restart_count || 0}</TableCell>
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10" title="终端"
onClick={() => (opts as any).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5 mr-1" />终端</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10" title="文件浏览器"
onClick={() => (opts as any).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" title="查看YAML"
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</div>
</TableCell>
</TableRow>
);
},
@@ -242,6 +253,8 @@ export default function NamespaceResourcePage() {
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [yamlItem, setYamlItem] = useState<{resource: string; name: string; editMode?: boolean} | null>(null);
const [terminalPod, setTerminalPod] = useState<any>(null);
const [fileBrowserPod, setFileBrowserPod] = useState<any>(null);
useEffect(() => {
kubernetesApi.getClusters().then((res: any) => {
@@ -386,12 +399,31 @@ export default function NamespaceResourcePage() {
) : filtered.length === 0 ? (
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-slate-500 py-12"><config.icon className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 {config.title}</TableCell></TableRow>
) : (
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml, onEdit: handleEdit }))
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml, onEdit: handleEdit, onTerminal: (p: any) => setTerminalPod(p), onFileBrowser: (p: any) => setFileBrowserPod(p) }))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* ============ Pod 终端 ============ */}
<PodExecTerminal
open={!!terminalPod}
onOpenChange={(open) => { if (!open) setTerminalPod(null); }}
clusterId={clusters[0]?.id || clusters[0]?.code || clusterCode || ''}
namespace={namespace || ''}
podName={terminalPod?.name || ''}
containers={(terminalPod?.containers || []).map((c: any) => c.name).filter(Boolean)}
/>
{/* ============ Pod 文件浏览器 ============ */}
<PodFileBrowser
open={!!fileBrowserPod}
onOpenChange={(open) => { if (!open) setFileBrowserPod(null); }}
clusterCode={clusterCode || ''}
namespace={namespace || ''}
podName={fileBrowserPod?.name || ''}
containers={(fileBrowserPod?.containers || []).map((c: any) => c.name).filter(Boolean)}
/>
</div>
);
}
@@ -37,13 +37,11 @@ import { useToast } from '@/hooks/use-toast';
*
* @author Logan <admin@attacker.club>
*/
interface PodExecTerminalProps {
export interface PodExecTerminalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 集群 ID(用于 ws 路由 :clusterId) */
clusterId: number | string;
/** 集群 code(备用,部分后端用 code) */
clusterCode?: string;
namespace: string;
podName: string;
/** 可选:Pod 容器名列表,缺省时仅显示 shell 选择 */
@@ -57,7 +55,6 @@ type ConnState = 'idle' | 'connecting' | 'connected' | 'error' | 'closed';
const SHELLS = [
{ value: 'bash', label: '/bin/bash' },
{ value: 'sh', label: '/bin/sh' },
{ value: 'sh', label: '/bin/ash' },
];
export function PodExecTerminal({
@@ -302,14 +299,15 @@ export function PodExecTerminal({
</Select>
)}
{/* Shell 选择 */}
{/* Shell 选择 — bash/sh 可选,默认 bash */}
<Select value={shell} onValueChange={setShell}>
<SelectTrigger className="h-8 w-[100px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<SelectTrigger className="h-8 w-[110px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">/bin/bash</SelectItem>
<SelectItem value="sh">/bin/sh</SelectItem>
{SHELLS.map(s => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
@@ -1,44 +1,16 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Folder, FolderOpen, FileText, FileCode, FileArchive, FileImage,
File, Download, Upload, Eye, ChevronRight, ArrowLeft, ArrowRight, ArrowUp,
RotateCw, Loader2, Maximize2, Minimize2, X, Box, Home, HardDrive,
AlertCircle, FileType, Binary,
} from 'lucide-react';
import { cn } from '@/lib/utils';
// PodFileBrowser.tsx — Pod 容器文件浏览器组件
// 作者: Logan <admin@attacker.club>
import { useState, useEffect, useCallback, useRef } from 'react';
import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
Loader2, X, Folder, File, ChevronRight, ChevronDown,
Download, Upload, RefreshCw, ArrowLeft, HardDrive,
} from 'lucide-react';
/**
* 容器文件浏览器 — 浏览 Pod 内文件系统
*
* - 调 POST .../pods/:pod/files/list {path:"/"} 获取目录
* - 调 GET .../pods/:pod/files/download?path=/xxx 下载文件
* - 面包屑导航 + 返回上级
* - 文件类型图标
* - 下载 / 查看文本(小文件预览)
* - 浏览器风格历史栈(前进 / 后退)
*
* 注:后端文件浏览器 API 并行开发中,UI 与 API 层先行;
* 返回结构兼容 {entries:[{name,isDir,size,modTime}]} 或数组。
*
* @author Logan <admin@attacker.club>
*/
interface PodFileBrowserProps {
export interface PodFileBrowserProps {
open: boolean;
onOpenChange: (open: boolean) => void;
clusterCode: string;
@@ -53,541 +25,217 @@ interface FileEntry {
isDir: boolean;
size?: number;
modTime?: string;
mode?: string;
}
// 文本预览大小阈值(超过则提示下载)
const TEXT_PREVIEW_MAX = 256 * 1024; // 256KB
// 可预览的扩展名
const TEXT_EXTENSIONS = new Set([
'.txt', '.log', '.conf', '.cfg', '.ini', '.yaml', '.yml', '.json',
'.xml', '.md', '.sh', '.bash', '.env', '.properties', '.toml',
'.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.java', '.rs', '.c',
'.cpp', '.h', '.hpp', '.css', '.html', '.sql', '.csv', '.gitignore',
'.dockerfile', '.makefile',
]);
function normalizePath(p: string): string {
if (!p) return '/';
// 去除重复斜杠
let s = p.replace(/\/+/g, '/');
if (!s.startsWith('/')) s = '/' + s;
if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
return s;
}
function joinPath(base: string, name: string): string {
return normalizePath(base === '/' ? `/${name}` : `${base}/${name}`);
}
function parentPath(p: string): string {
const n = normalizePath(p);
if (n === '/') return '/';
const idx = n.lastIndexOf('/');
return idx <= 0 ? '/' : n.slice(0, idx);
}
function pathSegments(p: string): { name: string; path: string }[] {
const n = normalizePath(p);
const segs = [{ name: '/', path: '/' }];
if (n === '/') return segs;
const parts = n.split('/').filter(Boolean);
let cur = '';
for (const part of parts) {
cur = cur === '' ? `/${part}` : `${cur}/${part}`;
segs.push({ name: part, path: cur });
}
return segs;
}
function getExt(name: string): string {
const idx = name.lastIndexOf('.');
if (idx === -1) return '';
// 处理 .gitignore 这类
if (idx === 0) return name.toLowerCase();
return name.slice(idx).toLowerCase();
}
function getFileIcon(name: string, isDir: boolean) {
if (isDir) return Folder;
const ext = getExt(name);
if (['.yaml', '.yml', '.json', '.xml', '.toml', '.ini', '.conf', '.cfg', '.env', '.properties'].includes(ext))
return FileCode;
if (['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.java', '.rs', '.c', '.cpp', '.h', '.hpp', '.sh', '.bash', '.sql'].includes(ext))
return FileCode;
if (['.log', '.txt', '.md', '.csv'].includes(ext)) return FileText;
if (['.tar', '.gz', '.zip', '.tgz', '.bz2', '.xz', '.7z', '.rar'].includes(ext)) return FileArchive;
if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico'].includes(ext)) return FileImage;
if (name.toLowerCase() === 'dockerfile' || ext === '.dockerfile') return FileType;
return File;
}
function formatSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return '-';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export function PodFileBrowser({
open,
onOpenChange,
clusterCode,
namespace,
podName,
containers = [],
initialContainer,
open, onOpenChange, clusterCode, namespace, podName, containers = [], initialContainer,
}: PodFileBrowserProps) {
const { toast } = useToast();
const [fullscreen, setFullscreen] = useState(false);
const [container, setContainer] = useState<string>(initialContainer || containers[0] || '');
const [container, setContainer] = useState(initialContainer || containers[0] || '');
const [currentPath, setCurrentPath] = useState('/');
const [entries, setEntries] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 浏览器风格历史栈
const [historyStack, setHistoryStack] = useState<string[]>(['/']);
const [historyIndex, setHistoryIndex] = useState(0);
const currentPath = historyStack[historyIndex] || '/';
// 预览
const [preview, setPreview] = useState<{ path: string; content: string; loading: boolean } | null>(null);
// 上传
const [preview, setPreview] = useState<{ file: string; content: string } | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 选中的条目(用于高亮)
const [selected, setSelected] = useState<string>('');
useEffect(() => {
if (!open) setFullscreen(false);
}, [open]);
useEffect(() => {
if (open && !container && containers.length > 0) {
setContainer(initialContainer || containers[0]);
}
}, [open, containers, initialContainer, container]);
// 加载目录
const loadPath = useCallback(async (path: string) => {
if (!podName) return;
const n = normalizePath(path);
const loadFiles = useCallback(async (path: string) => {
setLoading(true);
setError('');
setSelected('');
setPreview(null);
try {
const res: any = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, n, container || undefined);
// 兼容多种返回结构
let arr: any[] = [];
if (Array.isArray(res)) arr = res;
else if (Array.isArray(res?.entries)) arr = res.entries;
else if (Array.isArray(res?.items)) arr = res.items;
else if (Array.isArray(res?.files)) arr = res.files;
else if (Array.isArray(res?.data)) arr = res.data;
else if (res?.data && Array.isArray(res.data.entries)) arr = res.data.entries;
const list: FileEntry[] = arr.map((it: any) => ({
name: it.name ?? it.Name ?? it.path?.split('/').pop() ?? '',
isDir: Boolean(it.isDir ?? it.IsDir ?? it.dir ?? it.directory ?? (it.mode?.startsWith('d'))),
size: it.size ?? it.Size,
modTime: it.modTime ?? it.ModTime ?? it.mtime ?? it.modified,
mode: it.mode ?? it.Mode,
})).filter((e) => e.name && e.name !== '.');
// 排序:目录优先,再按名称
list.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
const res: any = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, path, container || undefined);
const list: FileEntry[] = res?.entries || res?.data?.entries || (Array.isArray(res) ? res : []);
setEntries(list);
setCurrentPath(path);
} catch (e: any) {
setError(e?.message || String(e));
setError(e.message || '加载文件列表失败');
setEntries([]);
// 后端 API 并行开发中,错误时给出友好提示
} finally {
setLoading(false);
}
}, [clusterCode, namespace, podName, container]);
// 打开 / 路径变化时加载
useEffect(() => {
if (open && podName) {
loadPath(currentPath);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, podName, currentPath, container]);
if (open) loadFiles('/');
}, [open, loadFiles]);
// 浏览器风格导航
const navigateTo = useCallback((path: string) => {
const n = normalizePath(path);
setHistoryStack((stack) => {
const next = stack.slice(0, historyIndex + 1);
// 避免重复
if (next[next.length - 1] !== n) next.push(n);
return next;
});
setHistoryIndex((i) => i + 1);
}, [historyIndex]);
const goBack = useCallback(() => {
if (historyIndex > 0) {
setHistoryIndex(historyIndex - 1);
}
}, [historyIndex]);
const goForward = useCallback(() => {
if (historyIndex < historyStack.length - 1) {
setHistoryIndex(historyIndex + 1);
}
}, [historyIndex, historyStack.length]);
const goUp = useCallback(() => {
navigateTo(parentPath(currentPath));
}, [currentPath, navigateTo]);
const canGoBack = historyIndex > 0;
const canGoForward = historyIndex < historyStack.length - 1;
// 进入目录 / 预览文件
const handleEntryClick = useCallback((entry: FileEntry) => {
setSelected(entry.name);
const fullPath = joinPath(currentPath, entry.name);
const handleClick = (entry: FileEntry) => {
if (entry.isDir) {
navigateTo(fullPath);
const newPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
loadFiles(newPath);
} else {
// 双击或点击预览文本文件
const ext = getExt(entry.name);
const isText = TEXT_EXTENSIONS.has(ext) || (entry.size ?? 0) <= TEXT_PREVIEW_MAX && !Binary;
if (isText && (entry.size ?? 0) <= TEXT_PREVIEW_MAX) {
previewFile(fullPath);
} else if ((entry.size ?? 0) > TEXT_PREVIEW_MAX && isText) {
toast({ title: '文件较大', description: `超过 ${formatSize(TEXT_PREVIEW_MAX)},建议下载查看`, variant: 'default' });
}
handlePreview(entry.name);
}
}, [currentPath, navigateTo, toast]);
};
// 预览文本文件
const previewFile = useCallback(async (path: string) => {
setPreview({ path, content: '', loading: true });
const handlePreview = async (fileName: string) => {
const filePath = currentPath === '/' ? `/${fileName}` : `${currentPath}/${fileName}`;
try {
const text = await kubernetesApi.readPodFileText(clusterCode, namespace, podName, path, container || undefined);
setPreview({ path, content: text, loading: false });
const text = await kubernetesApi.readPodFileText(clusterCode, namespace, podName, filePath, container || undefined);
setPreview({ file: fileName, content: text });
} catch (e: any) {
setPreview({ path, content: `[预览失败: ${e?.message || String(e)}]`, loading: false });
toast({ title: '预览失败', description: e?.message || String(e), variant: 'destructive' });
setPreview({ file: fileName, content: `读取失败: ${e.message}` });
}
}, [clusterCode, namespace, podName, container, toast]);
};
// 下载文件
const handleDownload = useCallback((path: string) => {
const url = kubernetesApi.buildPodFileDownloadUrl(clusterCode, namespace, podName, path, container || undefined);
const handleDownload = (fileName: string) => {
const filePath = currentPath === '/' ? `/${fileName}` : `${currentPath}/${fileName}`;
const url = kubernetesApi.buildPodFileDownloadUrl(clusterCode, namespace, podName, filePath, container || undefined);
window.open(url, '_blank');
toast({ title: '已开始下载', description: path });
}, [clusterCode, namespace, podName, container, toast]);
};
// 上传文件
const handleUpload = useCallback(async (files: FileList | null) => {
if (!files || files.length === 0) return;
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
for (const file of Array.from(files)) {
await kubernetesApi.uploadPodFile(clusterCode, namespace, podName, currentPath, file, container || undefined);
toast({ title: '上传成功', description: `${file.name} → ${currentPath}/${file.name}` });
}
// 刷新文件列表
loadPath(currentPath);
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || String(e), variant: 'destructive' });
await kubernetesApi.uploadPodFile(clusterCode, namespace, podName, currentPath, file, container || undefined);
loadFiles(currentPath);
} catch (err: any) {
setError(`上传失败: ${err.message}`);
} finally {
setUploading(false);
// 清空 input 允许重复上传同一文件
if (fileInputRef.current) fileInputRef.current.value = '';
}
}, [clusterCode, namespace, podName, container, currentPath, loadPath, toast]);
};
const breadcrumbs = useMemo(() => pathSegments(currentPath), [currentPath]);
const handleGoUp = () => {
if (currentPath === '/') return;
const parts = currentPath.split('/').filter(Boolean);
parts.pop();
loadFiles(parts.length === 0 ? '/' : `/${parts.join('/')}`);
};
const formatSize = (bytes?: number) => {
if (bytes == null) return '-';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const pathSegments = currentPath.split('/').filter(Boolean);
if (!open) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
onOpenAutoFocus={(e) => e.preventDefault()}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
: ''
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1000px', width: '94vw' }}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70" onClick={() => onOpenChange(false)}>
<div
className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-5xl h-[80vh] flex flex-col shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<DialogTitle className="sr-only">文件 — {podName}</DialogTitle>
<DialogDescription className="sr-only">浏览 Pod 容器文件系统</DialogDescription>
{/* 头部 */}
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--dg-border)] flex-wrap">
<div className="flex items-center gap-2 text-sm min-w-0">
<HardDrive className="h-4 w-4 text-blue-400 shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium truncate">文件浏览器 — {podName}</span>
{loading && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
<span className="text-xs text-[var(--dg-text-muted)]">{entries.length} 项</span>
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-slate-700 shrink-0">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-amber-400" />
<span className="text-sm font-semibold text-slate-200">文件浏览器: {podName}</span>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{namespace}</Badge>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
{containers.length > 0 && (
<Select value={container} onValueChange={setContainer}>
<SelectTrigger className="h-8 w-[150px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
<Box className="h-3 w-3 mr-1 text-[var(--dg-text-muted)]" />
<SelectValue placeholder="容器" />
</SelectTrigger>
<SelectContent>
{containers.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
<select
value={container}
onChange={(e) => { setContainer(e.target.value); }}
className="bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2 py-1"
>
{containers.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
)}
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => loadPath(currentPath)} title="刷新">
<RotateCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
title="上传文件"
>
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
multiple
onChange={(e) => handleUpload(e.target.files)}
/>
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title={fullscreen ? '退出全屏' : '全屏'}
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title="关闭"
>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={() => onOpenChange(false)}>
<X className="h-4 w-4" />
</button>
</Button>
</div>
</div>
{/* 工具栏:导航按钮 + 面包屑 */}
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] flex-wrap">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={!canGoBack} onClick={goBack} title="后退">
<ArrowLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={!canGoForward} onClick={goForward} title="前进">
<ArrowRight className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={currentPath === '/'} onClick={goUp} title="返回上级">
<ArrowUp className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => navigateTo('/')} title="根目录">
<Home className="h-4 w-4" />
</Button>
{/* Breadcrumb + Actions */}
<div className="flex items-center justify-between p-2 border-b border-slate-800 shrink-0">
<div className="flex items-center gap-1 text-xs text-slate-400">
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-500" onClick={handleGoUp} disabled={currentPath === '/'}>
<ArrowLeft className="h-3.5 w-3.5" />
</Button>
<button className="hover:text-slate-200" onClick={() => loadFiles('/')}>root</button>
{pathSegments.map((seg, i) => {
const path = `/${pathSegments.slice(0, i + 1).join('/')}`;
return (
<span key={path} className="flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
<button className="hover:text-slate-200" onClick={() => loadFiles(path)}>{seg}</button>
</span>
);
})}
</div>
<div className="flex items-center gap-2">
<input type="file" ref={fileInputRef} className="hidden" onChange={handleUpload} />
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Upload className="h-3 w-3 mr-1" />}上传
</Button>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={() => loadFiles(currentPath)} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</Button>
</div>
</div>
{/* 面包屑 */}
<div className="flex items-center gap-0.5 ml-2 flex-1 min-w-0 overflow-x-auto">
{breadcrumbs.map((seg, i) => (
<div key={seg.path} className="flex items-center shrink-0">
{i > 0 && <ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] mx-0.5" />}
<button
className={cn(
'px-1.5 py-0.5 rounded text-xs hover:bg-[var(--dg-bg-hover)]',
i === breadcrumbs.length - 1
? 'text-[var(--dg-text-primary)] font-medium'
: 'text-[var(--dg-text-secondary)]'
)}
onClick={() => navigateTo(seg.path)}
>
{seg.name === '/' ? <Home className="h-3 w-3" /> : seg.name}
</button>
{/* Content */}
<div className="flex-1 overflow-hidden flex">
{/* File list */}
<div className={`${preview ? 'w-1/2' : 'w-full'} overflow-auto p-2 border-r border-slate-800`}>
{error && <p className="text-xs text-red-400 p-2">{error}</p>}
{loading ? (
<div className="flex items-center justify-center h-32"><Loader2 className="h-5 w-5 animate-spin text-slate-500" /></div>
) : entries.length === 0 ? (
<p className="text-xs text-slate-500 p-4 text-center">空目录</p>
) : (
<div className="space-y-0.5">
{entries.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1)).map((entry) => (
<div
key={entry.name}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-slate-800/50 cursor-pointer group"
onClick={() => handleClick(entry)}
>
{entry.isDir ? (
<Folder className="h-4 w-4 text-blue-400 shrink-0" />
) : (
<File className="h-4 w-4 text-slate-500 shrink-0" />
)}
<span className={`text-xs truncate ${entry.isDir ? 'text-blue-300' : 'text-slate-300'}`}>
{entry.name}
</span>
{!entry.isDir && (
<span className="text-[10px] text-slate-600 ml-auto shrink-0">{formatSize(entry.size)}</span>
)}
{!entry.isDir && (
<Button
variant="ghost" size="icon" className="h-5 w-5 opacity-0 group-hover:opacity-100 text-slate-400 hover:text-emerald-400 shrink-0"
onClick={(e) => { e.stopPropagation(); handleDownload(entry.name); }}
title="下载"
>
<Download className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
))}
)}
</div>
<span className="text-xs text-[var(--dg-text-muted)] font-mono shrink-0">{currentPath}</span>
</div>
{/* 主体:左列表 / 右预览 */}
<div className="flex" style={{ height: fullscreen ? 'calc(100vh - 98px)' : '480px' }}>
{/* 文件列表 */}
<div className={cn('flex-1 overflow-auto', preview && 'border-r border-[var(--dg-border)]')}>
{error && (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
<AlertCircle className="h-8 w-8 text-amber-400 mb-2" />
<p className="text-sm text-[var(--dg-text-primary)] mb-1">加载失败</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-3 max-w-md">{error}</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-3 max-w-md">
文件浏览器 API 仍在并行开发中,请确认后端 <code className="px-1 bg-[var(--dg-bg-hover)] rounded">POST .../files/list</code> 已就绪。
</p>
<Button variant="outline" size="sm" onClick={() => loadPath(currentPath)}>
<RotateCw className="h-3.5 w-3.5 mr-1" />重试
{/* Preview */}
{preview && (
<div className="w-1/2 flex flex-col overflow-hidden">
<div className="flex items-center justify-between p-2 border-b border-slate-800 shrink-0">
<span className="text-xs text-slate-300 font-mono">{preview.file}</span>
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-400" onClick={() => setPreview(null)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)}
{!error && loading && entries.length === 0 && (
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
</div>
)}
{!error && !loading && entries.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-[var(--dg-text-muted)]">
<FolderOpen className="h-8 w-8 mb-2 opacity-50" />
<span className="text-sm">空目录</span>
</div>
)}
{!error && entries.length > 0 && (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)]">
<tr className="text-xs text-[var(--dg-text-muted)]">
<th className="text-left font-normal px-3 py-1.5">名称</th>
<th className="text-right font-normal px-3 py-1.5 w-28">大小</th>
<th className="text-left font-normal px-3 py-1.5 w-40">修改时间</th>
<th className="text-right font-normal px-3 py-1.5 w-28">操作</th>
</tr>
</thead>
<tbody>
{currentPath !== '/' && (
<tr
className="border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-hover)] cursor-pointer"
onClick={goUp}
>
<td className="px-3 py-1.5 text-[var(--dg-text-secondary)] flex items-center gap-2">
<ArrowUp className="h-4 w-4" />
<span className="italic">..</span>
</td>
<td className="px-3 py-1.5"></td>
<td className="px-3 py-1.5"></td>
<td className="px-3 py-1.5"></td>
</tr>
)}
{entries.map((entry) => {
const Icon = getFileIcon(entry.name, entry.isDir);
const fullPath = joinPath(currentPath, entry.name);
const isSelected = selected === entry.name;
return (
<tr
key={entry.name}
className={cn(
'border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-hover)] cursor-pointer',
isSelected && 'bg-[var(--dg-bg-hover)]'
)}
onClick={() => handleEntryClick(entry)}
>
<td className="px-3 py-1.5">
<div className="flex items-center gap-2 min-w-0">
<Icon className={cn(
'h-4 w-4 shrink-0',
entry.isDir ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'
)} />
<span className={cn(
'truncate',
entry.isDir ? 'text-[var(--dg-text-primary)] font-medium' : 'text-[var(--dg-text-secondary)]'
)} title={entry.name}>
{entry.name}
</span>
</div>
</td>
<td className="px-3 py-1.5 text-right text-xs text-[var(--dg-text-muted)] font-mono">
{entry.isDir ? '-' : formatSize(entry.size)}
</td>
<td className="px-3 py-1.5 text-xs text-[var(--dg-text-muted)] font-mono">
{entry.modTime || '-'}
</td>
<td className="px-3 py-1.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-0.5">
{!entry.isDir && (
<>
<button
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400"
title="预览文本"
onClick={() => previewFile(fullPath)}
>
<Eye className="h-3.5 w-3.5" />
</button>
</>
)}
<button
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-emerald-400"
title={entry.isDir ? '目录不支持下载' : '下载'}
disabled={entry.isDir}
onClick={() => !entry.isDir && handleDownload(fullPath)}
>
<Download className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* 预览面板 */}
{preview && (
<div className="flex flex-col w-[45%] min-w-[300px] bg-[#0f172a]">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-xs min-w-0">
<Eye className="h-3.5 w-3.5 text-blue-400 shrink-0" />
<span className="text-[var(--dg-text-primary)] truncate font-mono" title={preview.path}>
{preview.path}
</span>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-emerald-400"
title="下载"
onClick={() => handleDownload(preview.path)}
>
<Download className="h-3.5 w-3.5" />
</button>
<button
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
title="关闭预览"
onClick={() => setPreview(null)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-3">
{preview.loading ? (
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)]">
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
</div>
) : (
<pre className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all">
{preview.content || '(空文件)'}
</pre>
)}
<pre className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all bg-slate-950 rounded p-3">
{preview.content || '(空文件)'}
</pre>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}
@@ -1,14 +1,17 @@
// k8s-yaml-utils.ts — YAML 编辑前清理工具(借鉴 opsx-agent cleanYAML 设计)
// 不依赖 js-yaml;基于文本行级解析,针对 K8s 标准 manifest 结构清理。
// k8s-yaml-utils.ts — K8s YAML 编辑前深度清理工具
// 参考 opsx-agent clean_kubernetes_yaml() 设计,移除所有运行时/默认值/系统字段
// 作者: Logan <admin@attacker.club>
/**
* 编辑前清理 YAML 文本:
* - 移除 status 整块(K8s 服务端字段)
* - 移除 metadata 中的只读/噪音字段:uid / resourceVersion / managedFields /
* creationTimestamp / generation / selfLink / ownerReferences / resourceVersion
* - 移除 annotations 中的 kubectl.kubernetes.io/last-applied-configuration
* - 规范化行尾,保留可读缩进
* 编辑前深度清理 YAML 文本(Kuboard 级精简):
* - 移除 status 整块
* - 移除 metadata 噪音字段(uid/resourceVersion/managedFields/creationTimestamp 等)
* - 移除 annotations 中的 kubectl 系统注解
* - 移除 labels 中的 pod-template-hash
* - 移除 spec 中 K8s 默认值(dnsPolicy/restartPolicy/schedulerName 等)
* - 移除容器默认值(imagePullPolicy/terminationMessagePath 等)
* - 移除系统卷(kube-api-access-*)及对应 volumeMount
* - 移除 Deployment 默认字段(progressDeadlineSeconds/revisionHistoryLimit)
*/
export function cleanYAML(yaml: string): string {
if (!yaml) return '';
@@ -16,102 +19,346 @@ export function cleanYAML(yaml: string): string {
const out: string[] = [];
let i = 0;
// metadata 下需要整块移除的字段(值为多行块或对象)
const METADATA_BLOCK_KEYS = new Set([
'managedFields',
'ownerReferences',
]);
// metadata 下需要单行移除的字段
// ===== 移除配置 =====
const METADATA_LINE_KEYS = new Set([
'uid',
'resourceVersion',
'creationTimestamp',
'generation',
'selfLink',
'clusterName',
'managedFields', // 兜底
'uid', 'resourceVersion', 'creationTimestamp', 'generation',
'selfLink', 'clusterName', 'managedFields', 'generateName',
]);
const METADATA_BLOCK_KEYS = new Set(['managedFields', 'ownerReferences']);
// K8s 默认值 → 值匹配时移除
const SPEC_DEFAULTS: Record<string, string[]> = {
dnsPolicy: ['ClusterFirst'],
restartPolicy: ['Always'],
schedulerName: ['default-scheduler'],
terminationGracePeriodSeconds: ['30'],
serviceAccountName: ['default'],
enableServiceLinks: ['true'],
progressDeadlineSeconds: ['600'],
revisionHistoryLimit: ['10'],
};
const CONTAINER_DEFAULTS: Record<string, string[]> = {
imagePullPolicy: ['Always', 'IfNotPresent'],
terminationMessagePath: ['/dev/termination-log'],
terminationMessagePolicy: ['File'],
};
const ALWAYS_REMOVE_ANNOTATIONS = new Set([
'kubectl.kubernetes.io/last-applied-configuration',
'deployment.kubernetes.io/revision',
'kubectl.kubernetes.io/restartedAt',
'control-plane.alpha.kubernetes.io/leader',
'autoscaling.alpha.kubernetes.io/conditions',
]);
const SYSTEM_VOLUME_PREFIXES = ['kube-api-access-', 'default-token-', 'kube-'];
const SYSTEM_MOUNT_PATHS = ['/var/run/secrets/kubernetes.io/serviceaccount'];
// 当前作用域追踪
let inMetadata = false;
let inAnnotations = false;
let inSpec = false;
let inTemplateSpec = false;
let inContainers = false;
let inInitContainers = false;
let inVolumeMounts = false;
let inVolumes = false;
let metadataIndent = 0;
let specIndent = 0;
let templateSpecIndent = 0;
function getIndent(line: string): number {
return line.length - line.trimStart().length;
}
function extractKey(line: string): string | null {
const m = line.trimStart().match(/^([a-zA-Z_][a-zA-Z0-9_./-]*)\s*:/);
return m ? m[1] : null;
}
function hasInlineValue(line: string): boolean {
const afterColon = line.trim().split(':').slice(1).join(':').trim();
return afterColon !== '' && !/^[>|]/.test(afterColon);
}
// 跳过一个 YAML 块(包括当前行的所有子行)
function skipBlock(startIndent: number): void {
i++;
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { i++; continue; }
if (getIndent(l) <= startIndent) break;
i++;
}
}
// 检查当前行是否是某个 volume 条目的开始(系统卷)
function isSystemVolume(line: string): boolean {
const key = extractKey(line);
if (!key) return false;
return SYSTEM_VOLUME_PREFIXES.some(prefix => key.startsWith(prefix));
}
// 检查 volumeMount 是否指向系统挂载路径
function isSystemMountLine(line: string): boolean {
return SYSTEM_MOUNT_PATHS.some(p => line.includes(`mountPath: ${p}`));
}
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trimStart();
const indent = line.length - trimmed.length;
const indent = getIndent(line);
const key = extractKey(line);
// 1) 顶层 status: 整块移除(indent === 0)
if (indent === 0 && /^status\s*:/.test(trimmed)) {
// 空行和注释保留
if (trimmed === '' || trimmed.startsWith('#')) {
out.push(line);
i++;
// 跳过所有后续属于 status 块的行(缩进 > 0 或空行),直到下一个顶层 key
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { i++; continue; }
const ind = l.length - t.length;
if (ind === 0) break; // 回到顶层
i++;
}
continue;
}
// 2) metadata 子字段清理
// 匹配形如 " uid: xxx" 或 " managedFields:" 等
const metaKeyMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:/);
if (metaKeyMatch && indent >= 2 && indent <= 6) {
const key = metaKeyMatch[1];
// === 顶层 status: 整块移除 ===
if (indent === 0 && key === 'status') {
skipBlock(0);
continue;
}
// 单行移除
if (METADATA_LINE_KEYS.has(key)) {
// 若该行值非空(如 "uid: xxx"),直接跳过该行
// 若该行值为空(如 "managedFields:"),需要跳过其下属缩进块
const afterColon = trimmed.slice(metaKeyMatch[0].length).trim();
if (afterColon === '') {
// === 顶层 metadata: 跟踪作用域 ===
if (indent === 0 && key === 'metadata') {
inMetadata = true;
metadataIndent = 0;
out.push(line);
i++;
continue;
}
if (indent === 0 && key !== 'metadata') {
inMetadata = false;
inAnnotations = false;
}
// === 顶层 spec: 跟踪作用域 ===
if (indent === 0 && key === 'spec') {
inSpec = true;
specIndent = 0;
out.push(line);
i++;
continue;
}
if (indent === 0 && key !== 'spec') {
inSpec = false;
inTemplateSpec = false;
inContainers = false;
inInitContainers = false;
inVolumes = false;
}
// === metadata 子字段 ===
if (inMetadata) {
const metaIndent = 2; // metadata 子字段通常缩进 2
// 标注块开始/结束
if (indent === metaIndent && key === 'annotations') {
inAnnotations = true;
out.push(line);
i++;
continue;
}
if (indent <= metaIndent && key !== 'annotations') {
inAnnotations = false;
}
// labels 下的 pod-template-hash
if (indent >= metaIndent && indent <= metaIndent + 2) {
if (trimmed.startsWith('pod-template-hash') && trimmed.includes(':')) {
i++;
continue;
}
}
// metadata 单行字段移除
if (indent === metaIndent && key && METADATA_LINE_KEYS.has(key)) {
if (hasInlineValue(line)) {
i++;
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { i++; continue; }
const ind = l.length - t.length;
if (ind <= indent) break;
i++;
}
} else {
i++;
skipBlock(metaIndent);
}
continue;
}
// managedFields 块移除(即使有内联内容也整块跳过)
if (METADATA_BLOCK_KEYS.has(key)) {
// metadata 块字段移除(managedFields/ownerReferences)
if (indent === metaIndent && key && METADATA_BLOCK_KEYS.has(key)) {
skipBlock(metaIndent);
continue;
}
// annotations 内的系统注解
if (inAnnotations && indent > metaIndent) {
if (key && ALWAYS_REMOVE_ANNOTATIONS.has(key)) {
if (hasInlineValue(line)) {
i++;
} else {
// 多行 block scalar
const afterColon = trimmed.split(':').slice(1).join(':').trim();
if (/^[>|]/.test(afterColon)) {
skipBlock(indent);
} else {
i++;
}
}
continue;
}
}
}
// === spec 子字段 ===
if (inSpec) {
// spec.template.spec 跟踪
if (indent === 2 && key === 'template') {
out.push(line);
i++;
continue;
}
// 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++;
continue;
}
}
// spec 下的 nodeName(运行时分配)
if (indent === 2 && key === 'nodeName') {
i++;
continue;
}
// template.spec 内部
if (indent === 2 && key === 'spec') {
// 这是 template.spec 的开始
inTemplateSpec = true;
templateSpecIndent = indent;
out.push(line);
i++;
continue;
}
}
// === template.spec 内部 ===
if (inTemplateSpec && indent > templateSpecIndent) {
// containers 数组开始
if (key === 'containers') {
inContainers = true;
inInitContainers = false;
out.push(line);
i++;
continue;
}
if (key === 'initContainers') {
inInitContainers = true;
inContainers = false;
out.push(line);
i++;
continue;
}
// volumes 数组开始
if (key === 'volumes') {
inVolumes = true;
out.push(line);
i++;
continue;
}
// template.spec 默认值
if (key && SPEC_DEFAULTS[key]) {
const afterColon = trimmed.split(':').slice(1).join(':').trim();
if (SPEC_DEFAULTS[key].includes(afterColon)) {
i++;
continue;
}
}
// nodeName
if (key === 'nodeName') {
i++;
continue;
}
// serviceAccount (非 serviceAccountName 时的冗余字段)
if (key === 'serviceAccount' && trimmed.endsWith(':')) {
if (hasInlineValue(line)) {
i++;
} else {
skipBlock(indent);
}
continue;
}
}
// === containers/initContainers 内部 ===
if ((inContainers || inInitContainers) && indent >= 6) {
// 容器字段的默认值
if (key && CONTAINER_DEFAULTS[key]) {
const afterColon = trimmed.split(':').slice(1).join(':').trim();
if (CONTAINER_DEFAULTS[key].includes(afterColon)) {
i++;
continue;
}
}
// volumeMounts 区域
if (key === 'volumeMounts') {
inVolumeMounts = true;
// 不直接输出,等下面判断每个 mount
}
// 系统 volumeMount 条目
if (inVolumeMounts && trimmed.startsWith('- name:')) {
// 检查这个 volume mount 块内是否有系统路径
let blockLines: string[] = [line];
let j = i + 1;
let isSystem = false;
while (j < lines.length) {
const nl = lines[j].trimStart();
if (nl === '' || nl.startsWith('#')) { blockLines.push(lines[j]); j++; continue; }
if (getIndent(lines[j]) <= indent) break;
if (isSystemMountPath(nl)) isSystem = true;
blockLines.push(lines[j]);
j++;
}
if (isSystem) {
i = j; // 跳过整个 mount 块
continue;
}
// 非系统 mount,保留
inVolumeMounts = false;
}
}
// === volumes 区域 ===
if (inVolumes && indent >= 4) {
// 系统卷条目(- name: kube-api-access-xxx)
if (trimmed.startsWith('- name:') && isSystemVolume(trimmed)) {
// 跳过整个 volume 块
const blockIndent = indent;
i++;
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '' || t.startsWith('#')) { i++; continue; }
const ind = l.length - t.length;
if (ind <= indent) break;
if (getIndent(l) <= blockIndent) break;
i++;
}
continue;
}
}
// 3) annotations 内 last-applied-configuration 移除
// 形如 " kubectl.kubernetes.io/last-applied-configuration: >-"
// 可能跨多行(block scalar)
if (/kubectl\.kubernetes\.io\/last-applied-configuration\s*:/.test(trimmed)) {
const afterColon = trimmed.split(':').slice(1).join(':').trim();
// 多行 block scalar 标记:>- >- | |- 等
const isBlockScalar = /^[>|]/.test(afterColon);
// 连续空行压缩
if (trimmed === '' && out.length > 0 && out[out.length - 1].trim() === '') {
i++;
if (isBlockScalar) {
while (i < lines.length) {
const l = lines[i];
const t = l.trimStart();
if (t === '') { i++; continue; }
const ind = l.length - t.length;
if (ind <= indent) break;
i++;
}
}
continue;
}
@@ -119,27 +366,33 @@ export function cleanYAML(yaml: string): string {
i++;
}
// 末尾保持一个换行
// 末尾清理
let result = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
return result + '\n';
}
/** 解析简单 "key: value" 行(顶层或指定缩进),用于表单回填兜底。 */
function isSystemMountPath(line: string): boolean {
return line.includes('mountPath:') && (
line.includes('/var/run/secrets/kubernetes.io') ||
line.includes('/var/run/secrets/sa')
);
}
/** 解析简单 "key: value" 行 */
export function parseScalar(line: string): { key: string; value: string } | null {
const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
if (!m) return null;
return { key: m[1], value: m[2].trim().replace(/^["']|["']$/g, '') };
}
/** 状态色(与 opsx-agent 一致) */
/** 状态色 */
export const STATUS_COLORS = {
green: '#10b981',
orange: '#f59e0b',
red: '#ef4444',
blue: '#326CE5', // Kubernetes 官方蓝
blue: '#326CE5',
} as const;
/** 根据 Pod/Deployment 状态返回对应色值 */
export function statusColor(status: string | undefined): string {
if (!status) return '#64748b';
const s = status.toLowerCase();
@@ -149,23 +402,22 @@ export function statusColor(status: string | undefined): string {
return STATUS_COLORS.blue;
}
/** 按镜像聚合 ReplicaSet / Revision 列表(同镜像合并展示) */
/** 按镜像聚合 */
export function aggregateByImage<T extends { image?: string; images?: string[] }>(items: T[]): Array<T & { _count: number }> {
const map = new Map<string, T & { _count: number }>();
for (const it of items) {
const img = it.image || (Array.isArray(it.images) ? it.images[0] : '') || 'unknown';
const key = img;
const existing = map.get(key);
const existing = map.get(img);
if (existing) {
existing._count += 1;
} else {
map.set(key, { ...it, _count: 1 });
map.set(img, { ...it, _count: 1 });
}
}
return Array.from(map.values());
}
/** 格式化 K8s 时间戳(ISO / RFC3339)为本地可读格式 */
/** 格式化 K8s 时间戳 */
export function formatK8sTime(ts: string | undefined | null): string {
if (!ts) return '-';
const d = new Date(ts);
@@ -174,7 +426,6 @@ export function formatK8sTime(ts: string | undefined | null): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** 计算两个时间戳之间的相对耗时(秒/分/时),用于"调度耗时""就绪耗时"展示 */
export function elapsedSeconds(start?: string | null, end?: string | null): number | null {
if (!start || !end) return null;
const s = new Date(start).getTime();
@@ -183,7 +434,6 @@ export function elapsedSeconds(start?: string | null, end?: string | null): numb
return Math.max(0, Math.round((e - s) / 1000));
}
/** 把秒数格式化为 "1m23s" / "2h5m" / "45s" */
export function formatDuration(sec: number | null | undefined): string {
if (sec == null) return '-';
if (sec < 60) return `${sec}s`;