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:
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)]"
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user