Files
aiops/frontend/src/components/kubernetes/PodFileBrowser.tsx
T
attacker 5364d93ed7 fix(q1): 修复 tsc -b 构建中断 — 回退不安全的 unknown→any + 移除 Ansible 残留引用
- 781处 tsc -b 错误清零(unknown 属性访问/隐式any返回类型/类型转换不匹配)
- 移除 App.tsx 中已删除的 AnsibleManager/AnsibleWorkflow 导入和路由
- 修复 DeploymentView 中 _prefix 解构导致的属性不存在错误
- 修复 Header/Sidebar/PipelineEditor 中无效的类型断言
- asList() 返回类型 unknown[]→any[], 恢复 AgentMonitor 正常工作
- 放宽 noImplicitAny(临时), 待 types/ 定义完善后逐步恢复
- ESLint: unused=0, console=0, any=1041(Q1 后续降低)
- npm run build: ✅ 0 errors
2026-06-25 07:17:47 +08:00

283 lines
12 KiB
TypeScript

// PodFileBrowser.tsx — Pod 容器文件浏览器组件
// 作者: Logan <admin@attacker.club>
import { useState, useEffect, useCallback, useRef } from 'react';
import { kubernetesApi } from '@/services/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Loader2,
X,
Folder,
File,
ChevronRight,
Download,
Upload,
RefreshCw,
ArrowLeft,
HardDrive,
} from 'lucide-react';
export 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;
}
export function PodFileBrowser({
open, onOpenChange, clusterCode, namespace, podName, containers = [], initialContainer,
}: PodFileBrowserProps) {
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 [preview, setPreview] = useState<{ file: string; content: string } | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const workdirDetected = useRef(false);
const loadFiles = useCallback(async (path: string) => {
setLoading(true);
setError('');
setPreview(null);
try {
const res: Record<string, any> = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, path, container || undefined);
// 兼容后端 {files: [...]} 和 {entries: [...]} 两种返回格式
const raw: Record<string, any>[] = res?.entries || res?.files || res?.data?.entries || res?.data?.files || (Array.isArray(res) ? res : []);
const list: FileEntry[] = raw.map(( it: any) => ({
name: it.name ?? it.Name ?? '',
isDir: Boolean(it.isDir ?? it.is_dir ?? it.IsDir ?? it.dir ?? it.directory),
size: typeof it.size === 'number' ? it.size : (it.Size ? Number(it.Size) : undefined),
modTime: it.modTime ?? it.mod_time ?? it.ModTime ?? it.mtime ?? it.modified,
})).filter(e => e.name && e.name !== '.');
setEntries(list);
setCurrentPath(path);
} catch (e: any) {
setError((e as Record<string, any>).message || '加载文件列表失败');
setEntries([]);
} finally {
setLoading(false);
}
}, [clusterCode, namespace, podName, container]);
useEffect(() => {
if (open) {
// 检测容器 WORKDIR 作为初始路径(执行 pwd 命令)
const detectWorkdir = async () => {
if (workdirDetected.current) {
loadFiles(currentPath);
return;
}
setLoading(true);
try {
const res: Record<string, any> = await kubernetesApi.execPod(clusterCode, namespace, podName, ['pwd'], container || undefined);
const stdout = (res?.stdout || res?.data?.stdout || '').trim();
const workdir = stdout && stdout.startsWith('/') ? stdout : '/';
workdirDetected.current = true;
setCurrentPath(workdir);
loadFiles(workdir);
} catch {
// pwd 失败则默认 /
workdirDetected.current = true;
loadFiles('/');
}
};
detectWorkdir();
} else {
workdirDetected.current = false;
}
}, [open, container]);
const handleClick = (entry: FileEntry) => {
if (entry.isDir) {
const newPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
loadFiles(newPath);
} else {
handlePreview(entry.name);
}
};
const handlePreview = async (fileName: string) => {
const filePath = currentPath === '/' ? `/${fileName}` : `${currentPath}/${fileName}`;
try {
const text = await kubernetesApi.readPodFileText(clusterCode, namespace, podName, filePath, container || undefined);
setPreview({ file: fileName, content: text });
} catch (e: any) {
setPreview({ file: fileName, content: `读取失败: ${(e as Record<string, any>).message}` });
}
};
const handleDownload = (fileName: string) => {
const filePath = currentPath === '/' ? `/${fileName}` : `${currentPath}/${fileName}`;
const url = kubernetesApi.buildPodFileDownloadUrl(clusterCode, namespace, podName, filePath, container || undefined);
window.open(url, '_blank');
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
await kubernetesApi.uploadPodFile(clusterCode, namespace, podName, currentPath, file, container || undefined);
loadFiles(currentPath);
} catch (err: any) {
setError(`上传失败: ${(err as Record<string, any>).message}`);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
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 (
<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()}
>
{/* 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-2">
{containers.length > 0 && (
<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="icon" className="h-7 w-7 text-slate-400" onClick={() => onOpenChange(false)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* 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>
{/* 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>
{/* 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>
<div className="flex-1 overflow-auto p-3">
<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>
</div>
</div>
);
}