feat(k8s): M3 Pod文件上传 + ExecInPod WebSocket协议重写

M3: Pod 文件上传
- 后端: POST .../pods/:pod/files/upload (multipart, base64分块exec写入)
- 前端: PodFileBrowser 增加上传按钮 + FormData API
- 路由: 新增 Kuboard (:clusterCode) + 旧 (:id) 两套路由

重大修复: ExecInPod 从 raw HTTP SPDY 改为 WebSocket channel.k8s.io
- 旧方案: HTTP POST + SPDY upgrade → 无法解析响应(协议头缺失 + 帧解析复杂)
- 新方案: gorilla/websocket Dialer + channel.k8s.io 子协议 → 和 ExecTerminal 一致
- 影响: 文件浏览/下载/上传全部恢复正常

修复: BusyBox ls 解析
- BusyBox 不支持 --time-style 参数,默认输出 Mon DD HH:MM 格式
- 解析器改为自动检测 GNU vs BusyBox 日期格式
This commit is contained in:
attacker committed 2026-06-23 02:21:29 +08:00
1 parent 69df073d42
commit 9ab7572b8e
11 files changed
+389 -126

No files matched your search

@@ -107,19 +107,17 @@ export function PodExecTerminal({
term.clear();
term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} ...]\x1b[0m`);
const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined);
const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined, shell);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setConnState('connected');
term.reset();
// 发送初始 resize + 启动 shell
// 发送初始 resize(command/shell 已在 WS URL 参数中指定)
try {
const { cols, rows } = term;
ws.send(JSON.stringify({ status: 1, cols, rows }));
// 启动 shell 进程:标准 remote exec 协议 (status:0 data)
ws.send(JSON.stringify({ status: 0, data: `${shell}\r` }));
} catch { /* noop */ }
setTimeout(() => { try { term.focus(); } catch { /* noop */ } }, 100);
};
@@ -15,7 +15,7 @@ import {
} from '@/components/ui/select';
import {
Folder, FolderOpen, FileText, FileCode, FileArchive, FileImage,
File, Download, Eye, ChevronRight, ArrowLeft, ArrowRight, ArrowUp,
File, Download, Upload, Eye, ChevronRight, ArrowLeft, ArrowRight, ArrowUp,
RotateCw, Loader2, Maximize2, Minimize2, X, Box, Home, HardDrive,
AlertCircle, FileType, Binary,
} from 'lucide-react';
@@ -156,6 +156,10 @@ export function PodFileBrowser({
// 预览
const [preview, setPreview] = useState<{ path: string; content: string; loading: boolean } | null>(null);
// 上传
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 选中的条目(用于高亮)
const [selected, setSelected] = useState<string>('');
@@ -287,6 +291,26 @@ export function PodFileBrowser({
toast({ title: '已开始下载', description: path });
}, [clusterCode, namespace, podName, container, toast]);
// 上传文件
const handleUpload = useCallback(async (files: FileList | null) => {
if (!files || files.length === 0) 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' });
} finally {
setUploading(false);
// 清空 input 允许重复上传同一文件
if (fileInputRef.current) fileInputRef.current.value = '';
}
}, [clusterCode, namespace, podName, container, currentPath, loadPath, toast]);
const breadcrumbs = useMemo(() => pathSegments(currentPath), [currentPath]);
return (
@@ -331,6 +355,23 @@ export function PodFileBrowser({
<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)]"
+31 -2
View File
@@ -249,12 +249,13 @@ export const kubernetesApi = {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/exec`, { command, container });
},
// 🆕 Pod 级 exec WebSocket URL (Logan <admin@attacker.club>)
// ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&token=
// ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&command=&token=
buildPodExecWsUrl: (
clusterId: number | string,
namespace: string,
podName: string,
container?: string
container?: string,
command?: string
) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${proto}//${window.location.host}`;
@@ -262,6 +263,7 @@ export const kubernetesApi = {
const token = useUserStore.getState().token || '';
const params = new URLSearchParams();
if (container) params.set('container', container);
if (command) params.set('command', command);
if (token) params.set('token', token);
const qs = params.toString();
return `${wsBase}/ws/kubernetes-exec/${clusterId}/namespaces/${namespace}/pods/${podName}${qs ? `?${qs}` : ''}`;
@@ -309,6 +311,33 @@ export const kubernetesApi = {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.text();
},
// 上传文件到 Pod 容器内指定目录
uploadPodFile: async (
clusterCode: string,
namespace: string,
podName: string,
targetPath: string,
file: File,
container?: string,
) => {
const formData = new FormData();
formData.append('file', file);
formData.append('path', targetPath);
if (container) formData.append('container', container);
const token = useUserStore.getState().token || localStorage.getItem('token') || '';
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${window.location.protocol}//${window.location.host}`;
const resp = await fetch(
`${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/files/upload`,
{
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
},
);
const json = await resp.json();
if (!resp.ok || json.code !== 200) throw new Error(json.message || `HTTP ${resp.status}`);
return json.data;
},
scaleDeployment: async (clusterCode: string, namespace: string, name: string, replicas: number) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/scale`, { replicas });
},