fix: K8s 集群添加缺少 code 必填字段

- KubernetesClusterDialog: 新增 code 表单字段
- 名称变更自动生成 code(小写+连字符)
- handleSubmit 增加 code 校验 + toast 提示
- payload 增加 code 字段(后端 binding:required)
This commit is contained in:
attacker committed 2026-06-21 09:50:07 +00:00
1 parent f24fe43ac0
commit ce438c9f05
18 files changed
+3941 -144

No files matched your search

@@ -0,0 +1,552 @@
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, Eye, ChevronRight, ArrowLeft, ArrowRight, ArrowUp,
RotateCw, Loader2, Maximize2, Minimize2, X, Box, Home, HardDrive,
AlertCircle, FileType, Binary,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
/**
* 容器文件浏览器 — 浏览 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 {
open: boolean;
onOpenChange: (open: boolean) => void;
clusterCode: string;
namespace: string;
podName: string;
containers?: string[];
initialContainer?: string;
}
interface FileEntry {
name: string;
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,
}: PodFileBrowserProps) {
const { toast } = useToast();
const [fullscreen, setFullscreen] = useState(false);
const [container, setContainer] = useState<string>(initialContainer || containers[0] || '');
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 [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);
setLoading(true);
setError('');
setSelected('');
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);
});
setEntries(list);
} catch (e: any) {
setError(e?.message || String(e));
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]);
// 浏览器风格导航
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);
if (entry.isDir) {
navigateTo(fullPath);
} 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' });
}
}
}, [currentPath, navigateTo, toast]);
// 预览文本文件
const previewFile = useCallback(async (path: string) => {
setPreview({ path, content: '', loading: true });
try {
const text = await kubernetesApi.readPodFileText(clusterCode, namespace, podName, path, container || undefined);
setPreview({ path, content: text, loading: false });
} catch (e: any) {
setPreview({ path, content: `[预览失败: ${e?.message || String(e)}]`, loading: false });
toast({ title: '预览失败', description: e?.message || String(e), variant: 'destructive' });
}
}, [clusterCode, namespace, podName, container, toast]);
// 下载文件
const handleDownload = useCallback((path: string) => {
const url = kubernetesApi.buildPodFileDownloadUrl(clusterCode, namespace, podName, path, container || undefined);
window.open(url, '_blank');
toast({ title: '已开始下载', description: path });
}, [clusterCode, namespace, podName, container, toast]);
const breadcrumbs = useMemo(() => pathSegments(currentPath), [currentPath]);
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' }}
>
<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>
</div>
<div className="flex items-center gap-1">
{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>
)}
<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
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="关闭"
>
<X className="h-4 w-4" />
</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>
{/* 面包屑 */}
<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>
</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" />重试
</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>
)}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}