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:
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`;
|
||||
|
||||
Reference in new issue
Block a user