Improve WebCRT SFTP transfers and file-panel UX.

Stream large uploads/downloads with progress, raise the size cap, and support drag-drop folders plus fixed/resizable columns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 01:10:30 +08:00
parent 7d8b698b91
commit d10ffed277
9 changed files with 769 additions and 120 deletions

View file

@ -101,6 +101,9 @@ class Settings(BaseSettings):
webcrt_session_log_enabled: bool = True
# Reader: short blocking wait instead of fixed 40ms spin (seconds).
webcrt_reader_poll_sec: float = 0.01
# WebCRT SFTP transfer limits (streamed; default 512 MiB per file).
webcrt_sftp_max_file_bytes: int = 512 * 1024 * 1024
webcrt_sftp_chunk_bytes: int = 64 * 1024
# Local app login / audit (lab defaults; override in production)
auth_enabled: bool = True
# Stable default so JWT survives restarts without .env. Override in production.

View file

@ -213,19 +213,26 @@ def api_sftp_list(body: WebcrtSftpListBody, db: Session = Depends(get_db)) -> di
@router.post("/sftp/download")
def api_sftp_download(body: WebcrtSftpDownloadBody, db: Session = Depends(get_db)) -> Any:
from fastapi.responses import Response
from fastapi.responses import StreamingResponse
from .webcrt_sftp import sftp_download
from .webcrt_sftp import SftpDownloadStream
mid = str(body.ne_id or "").strip()
uid = str(body.ume_ne_id or "").strip()
if bool(mid) == bool(uid):
raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required")
data, filename = sftp_download(db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path)
return Response(
content=data,
stream = SftpDownloadStream(
db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path
).open()
headers = {
"Content-Disposition": stream.content_disposition(),
"Content-Length": str(int(stream.size)),
"Cache-Control": "no-store",
}
return StreamingResponse(
stream,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
headers=headers,
)
@ -237,22 +244,31 @@ async def api_sftp_upload(
remote_path: str = Form(...),
file: UploadFile = File(...),
) -> dict[str, Any]:
from .webcrt_sftp import sftp_upload
from starlette.concurrency import run_in_threadpool
from .config import settings
from .webcrt_sftp import sftp_upload_stream
mid = str(ne_id or "").strip()
uid = str(ume_ne_id or "").strip()
if bool(mid) == bool(uid):
raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required")
content = await file.read()
if len(content) > 8 * 1024 * 1024:
max_bytes = max(1, int(settings.webcrt_sftp_max_file_bytes or (512 * 1024 * 1024)))
expected = getattr(file, "size", None)
if expected is not None and int(expected) > max_bytes:
raise HTTPException(status_code=413, detail="sftp_file_too_large")
return sftp_upload(
db,
managed_ne_id=mid or None,
ume_ne_id=uid or None,
remote_path=remote_path,
data=content,
)
def _upload() -> dict[str, Any]:
return sftp_upload_stream(
db,
managed_ne_id=mid or None,
ume_ne_id=uid or None,
remote_path=remote_path,
reader=file.file,
expected_size=int(expected) if expected is not None else None,
)
return await run_in_threadpool(_upload)
@router.websocket("/sessions/{session_id}/ws")

View file

@ -7,9 +7,10 @@ import posixpath
import stat as statmod
import threading
import time
from contextlib import contextmanager
from contextlib import ExitStack, contextmanager
from dataclasses import dataclass, field
from typing import Any, Iterator
from typing import Any, BinaryIO, Iterator
from urllib.parse import quote
import paramiko
from fastapi import HTTPException
@ -201,6 +202,43 @@ def _owner_group(attr: Any) -> tuple[str, str]:
return ("" if uid is None else str(uid), "" if gid is None else str(gid))
def _mkdir_p(sftp: Any, remote: str) -> None:
"""Create remote directory and parents (like mkdir -p)."""
path = _normalize_remote(remote, allow_dot=False)
if not path or path in (".", "/"):
return
to_create: list[str] = []
cur = path
while cur and cur not in (".", "/"):
to_create.append(cur)
parent = posixpath.dirname(cur)
if parent == cur:
break
cur = parent
for candidate in reversed(to_create):
try:
st = sftp.stat(candidate)
mode = int(getattr(st, "st_mode", 0) or 0)
if not statmod.S_ISDIR(mode):
raise HTTPException(status_code=400, detail=f"sftp_not_a_directory:{candidate}")
continue
except HTTPException:
raise
except Exception:
pass
try:
sftp.mkdir(candidate)
except Exception as exc:
# Race: another client created it; accept if it is now a directory.
try:
st = sftp.stat(candidate)
if statmod.S_ISDIR(int(getattr(st, "st_mode", 0) or 0)):
continue
except Exception:
pass
raise HTTPException(status_code=502, detail=f"sftp_mkdir_failed:{candidate}:{exc}") from exc
def sftp_list(
db: Session,
*,
@ -244,27 +282,148 @@ def sftp_list(
raise HTTPException(status_code=502, detail=f"sftp_list_failed:{exc}") from exc
def sftp_download(
def _sftp_max_file_bytes() -> int:
return max(1, int(settings.webcrt_sftp_max_file_bytes or (512 * 1024 * 1024)))
def _sftp_chunk_bytes() -> int:
return max(4 * 1024, int(settings.webcrt_sftp_chunk_bytes or (64 * 1024)))
def _content_disposition(filename: str) -> str:
name = str(filename or "download.bin").replace('"', "").replace("\r", "").replace("\n", "")
if not name:
name = "download.bin"
ascii_name = name.encode("ascii", "replace").decode("ascii") or "download.bin"
return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(name)}"
class SftpDownloadStream:
"""Hold the SFTP channel open while StreamingResponse consumes chunks."""
def __init__(
self,
db: Session,
*,
managed_ne_id: str | None,
ume_ne_id: str | None,
path: str,
) -> None:
self._db = db
self._managed_ne_id = managed_ne_id
self._ume_ne_id = ume_ne_id
self.remote = _normalize_remote(path, allow_dot=False)
self.filename = "download.bin"
self.size = 0
self._stack: ExitStack | None = None
self._fh: Any = None
self._chunk = _sftp_chunk_bytes()
def open(self) -> "SftpDownloadStream":
if not self.remote or self.remote.endswith("/"):
raise HTTPException(status_code=400, detail="sftp_path_required")
stack = ExitStack()
try:
sftp, _device = stack.enter_context(
_sftp_client(self._db, managed_ne_id=self._managed_ne_id, ume_ne_id=self._ume_ne_id)
)
try:
st = sftp.stat(self.remote)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"sftp_download_failed:{exc}") from exc
mode = int(getattr(st, "st_mode", 0) or 0)
if statmod.S_ISDIR(mode):
raise HTTPException(status_code=400, detail="sftp_path_is_directory")
size = int(getattr(st, "st_size", 0) or 0)
if size > _sftp_max_file_bytes():
raise HTTPException(status_code=413, detail="sftp_file_too_large")
self.size = max(0, size)
self.filename = posixpath.basename(self.remote) or "download.bin"
try:
self._fh = stack.enter_context(sftp.open(self.remote, "rb"))
except Exception as exc:
raise HTTPException(status_code=502, detail=f"sftp_download_failed:{exc}") from exc
except Exception:
stack.close()
raise
self._stack = stack
return self
def __iter__(self) -> Iterator[bytes]:
fh = self._fh
if fh is None:
return
try:
while True:
chunk = fh.read(self._chunk)
if not chunk:
break
yield chunk
finally:
self.close()
def close(self) -> None:
stack = self._stack
self._stack = None
self._fh = None
if stack is not None:
try:
stack.close()
except Exception:
pass
def content_disposition(self) -> str:
return _content_disposition(self.filename)
def sftp_upload_stream(
db: Session,
*,
managed_ne_id: str | None,
ume_ne_id: str | None,
path: str,
) -> tuple[bytes, str]:
remote = _normalize_remote(path, allow_dot=False)
if not remote or remote.endswith("/"):
remote_path: str,
reader: BinaryIO,
expected_size: int | None = None,
) -> dict[str, Any]:
remote = _normalize_remote(remote_path, allow_dot=False)
if not remote:
raise HTTPException(status_code=400, detail="sftp_path_required")
max_bytes = _sftp_max_file_bytes()
if expected_size is not None and int(expected_size) > max_bytes:
raise HTTPException(status_code=413, detail="sftp_file_too_large")
chunk_size = _sftp_chunk_bytes()
try:
with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, _device):
with sftp.open(remote, "rb") as fh:
data = fh.read(8 * 1024 * 1024 + 1)
if len(data) > 8 * 1024 * 1024:
raise HTTPException(status_code=413, detail="sftp_file_too_large")
return data, posixpath.basename(remote) or "download.bin"
with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device):
parent = posixpath.dirname(remote)
if parent and parent not in (".", "/"):
_mkdir_p(sftp, parent)
written = 0
try:
with sftp.open(remote, "wb") as fh:
while True:
buf = reader.read(chunk_size)
if not buf:
break
written += len(buf)
if written > max_bytes:
raise HTTPException(status_code=413, detail="sftp_file_too_large")
fh.write(buf)
except HTTPException:
try:
sftp.remove(remote)
except Exception:
pass
raise
return {
"ok": True,
"ne_id": str(device.get("id") or ""),
"path": remote,
"size": written,
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"sftp_download_failed:{exc}") from exc
raise HTTPException(status_code=502, detail=f"sftp_upload_failed:{exc}") from exc
def sftp_upload(
@ -275,20 +434,14 @@ def sftp_upload(
remote_path: str,
data: bytes,
) -> dict[str, Any]:
remote = _normalize_remote(remote_path, allow_dot=False)
if not remote:
raise HTTPException(status_code=400, detail="sftp_path_required")
try:
with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device):
with sftp.open(remote, "wb") as fh:
fh.write(data)
return {
"ok": True,
"ne_id": str(device.get("id") or ""),
"path": remote,
"size": len(data),
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"sftp_upload_failed:{exc}") from exc
"""Compatibility helper for tests — wraps streamed upload over an in-memory buffer."""
import io
return sftp_upload_stream(
db,
managed_ne_id=managed_ne_id,
ume_ne_id=ume_ne_id,
remote_path=remote_path,
reader=io.BytesIO(data or b""),
expected_size=len(data or b""),
)

View file

@ -748,6 +748,41 @@ class WebcrtServiceTests(unittest.TestCase):
owner2, group2 = _owner_group(SimpleNamespace(longname="", st_uid=0, st_gid=1))
self.assertEqual((owner2, group2), ("0", "1"))
def test_sftp_mkdir_p(self) -> None:
from netx_api.webcrt_sftp import _mkdir_p
class _FakeSftp:
def __init__(self) -> None:
self.dirs: set[str] = {"/"}
self.mkdir_calls: list[str] = []
def stat(self, path: str) -> object:
if path in self.dirs or path == "/":
return type("St", (), {"st_mode": 0o040755})()
raise FileNotFoundError(path)
def mkdir(self, path: str) -> None:
self.mkdir_calls.append(path)
self.dirs.add(path)
sftp = _FakeSftp()
_mkdir_p(sftp, "/a/b/c")
self.assertEqual(sftp.mkdir_calls, ["/a", "/a/b", "/a/b/c"])
_mkdir_p(sftp, "/a/b/c") # idempotent
self.assertEqual(sftp.mkdir_calls, ["/a", "/a/b", "/a/b/c"])
def test_sftp_transfer_helpers(self) -> None:
from netx_api import config as cfg
from netx_api.webcrt_sftp import _content_disposition, _sftp_chunk_bytes, _sftp_max_file_bytes
self.assertGreaterEqual(_sftp_max_file_bytes(), 8 * 1024 * 1024)
self.assertGreaterEqual(_sftp_chunk_bytes(), 4 * 1024)
dispo = _content_disposition('报告"A".bin')
self.assertIn("filename=", dispo)
self.assertIn("filename*=UTF-8''", dispo)
self.assertNotIn("\n", dispo)
self.assertEqual(int(cfg.settings.webcrt_sftp_max_file_bytes), 512 * 1024 * 1024)
@patch.object(svc, "_audit")
def test_find_ssh_session_for_ne_prefers_attached(self, _mock_audit: MagicMock) -> None:
conn = _FakeConn()

View file

@ -1045,6 +1045,11 @@ const en = {
up: "Up",
upload: "Upload",
uploaded: "Upload complete",
uploading: "Upload {{done}}/{{total}} · {{name}} {{pct}}",
downloading: "Download {{name}} {{pct}}",
uploadPartial: "Uploaded {{ok}}, failed {{failed}}",
dropHint: "Drop to upload into current folder",
dropEmpty: "No uploadable files found",
dir: "dir",
loading: "Loading…",
enterHint: "Double-click to open folder",
@ -1056,6 +1061,7 @@ const en = {
colGroup: "Group",
colMode: "Access",
resizeHint: "Drag to resize SFTP / terminal",
colResizeHint: "Drag to resize column",
},
term: {
findPh: "Find…",
@ -1084,6 +1090,7 @@ const en = {
sftpSsh: "SFTP requires SSH",
sftpNeedPassword: "SFTP needs a saved SSH password (one-shot dialog passwords are not reused for files)",
sftpUnsupported: "This SSH session has no SFTP channel (device may not offer it)",
sftpTooLarge: "File exceeds size limit (default 512MB)",
queueDropped: "Terminal output too fast; about {{count}} chunk(s) dropped — transcript may be incomplete",
websocket: "WebSocket connection failed",
},

View file

@ -1042,6 +1042,11 @@ const zh = {
up: "上级",
upload: "上传",
uploaded: "上传成功",
uploading: "上传 {{done}}/{{total}} · {{name}} {{pct}}",
downloading: "下载 {{name}} {{pct}}",
uploadPartial: "上传完成 {{ok}} 个,失败 {{failed}} 个",
dropHint: "松开以上传到当前目录",
dropEmpty: "未识别到可上传的文件",
dir: "目录",
loading: "正在加载…",
enterHint: "双击进入目录",
@ -1053,6 +1058,7 @@ const zh = {
colGroup: "组",
colMode: "权限",
resizeHint: "拖动调整 SFTP / 终端宽度",
colResizeHint: "拖动调整列宽",
},
term: {
findPh: "查找…",
@ -1081,6 +1087,7 @@ const zh = {
sftpSsh: "SFTP 仅支持 SSH 协议",
sftpNeedPassword: "SFTP 需要已保存的 SSH 密码(会话弹窗密码不会用于文件传输)",
sftpUnsupported: "当前 SSH 会话未提供 SFTP(设备可能未开启)",
sftpTooLarge: "文件超过大小限制(默认 512MB)",
queueDropped: "终端输出过快,已丢弃约 {{count}} 段,内容可能不完整",
websocket: "WebSocket 连接失败",
},

View file

@ -3083,6 +3083,28 @@ pre {
cursor: progress;
}
.webcrt-sftp.is-drop-target {
outline: 1px solid #0ea5e9;
outline-offset: -1px;
}
.webcrt-sftp__drop-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: #e0f2fe;
background: rgba(14, 165, 233, 0.18);
border: 1px dashed #38bdf8;
pointer-events: none;
}
.webcrt-sftp__bar {
display: flex;
align-items: center;
@ -3151,7 +3173,6 @@ pre {
}
.webcrt-sftp__table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 11px;
@ -3170,10 +3191,44 @@ pre {
text-align: left;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 4px 6px;
padding: 4px 8px 4px 6px;
border-bottom: 1px solid #334155;
white-space: nowrap;
user-select: none;
overflow: hidden;
}
.webcrt-sftp__th-label {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 4px;
}
.webcrt-sftp__col-resizer {
position: absolute;
top: 0;
right: 0;
width: 5px;
height: 100%;
cursor: col-resize;
z-index: 2;
}
.webcrt-sftp__col-resizer::after {
content: "";
position: absolute;
top: 2px;
bottom: 2px;
right: 1px;
width: 1px;
background: transparent;
}
.webcrt-sftp__col-resizer:hover::after,
.webcrt-sftp__col-resizer:active::after {
background: #0ea5e9;
}
.webcrt-sftp__table tbody tr {
@ -3200,12 +3255,10 @@ pre {
}
.webcrt-sftp__col-name {
width: 34%;
color: #e2e8f0;
}
.webcrt-sftp__col-size {
width: 12%;
text-align: right;
font-variant-numeric: tabular-nums;
color: #64748b;
@ -3216,19 +3269,16 @@ pre {
}
.webcrt-sftp__col-mtime {
width: 22%;
font-variant-numeric: tabular-nums;
color: #64748b;
}
.webcrt-sftp__col-owner,
.webcrt-sftp__col-group {
width: 10%;
color: #64748b;
}
.webcrt-sftp__col-mode {
width: 12%;
font-family: Consolas, "Courier New", monospace;
font-size: 10px;
color: #475569;

View file

@ -1,4 +1,12 @@
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal";
@ -36,6 +44,28 @@ const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs";
const SFTP_WIDTH_KEY = "netx.webcrt.sftpWidth";
const SFTP_WIDTH_DEFAULT = 480;
const SFTP_WIDTH_MIN = 280;
const SFTP_COL_WIDTHS_KEY = "netx.webcrt.sftpColWidths";
type SftpColKey = "name" | "size" | "mtime" | "owner" | "group" | "mode";
type SftpColWidths = Record<SftpColKey, number>;
const SFTP_COL_KEYS: SftpColKey[] = ["name", "size", "mtime", "owner", "group", "mode"];
const SFTP_COL_WIDTH_DEFAULTS: SftpColWidths = {
name: 220,
size: 72,
mtime: 140,
owner: 72,
group: 72,
mode: 88,
};
const SFTP_COL_WIDTH_MIN: SftpColWidths = {
name: 120,
size: 48,
mtime: 96,
owner: 48,
group: 48,
mode: 64,
};
const ENCODING_OPTIONS = ["utf-8", "gbk", "gb2312", "gb18030"] as const;
const FONT_SIZE_OPTIONS = [12, 13, 14, 16, 18, 20] as const;
const PASTE_DELAY_OPTIONS = [0, 20, 40, 60, 100, 150] as const;
@ -400,6 +430,48 @@ function saveSftpWidth(width: number): void {
}
}
function clampSftpColWidth(key: SftpColKey, width: number): number {
const min = SFTP_COL_WIDTH_MIN[key];
const n = Math.round(Number(width) || 0);
if (!Number.isFinite(n)) return SFTP_COL_WIDTH_DEFAULTS[key];
return Math.max(min, Math.min(n, 640));
}
function normalizeSftpColWidths(raw: unknown): SftpColWidths {
const base = { ...SFTP_COL_WIDTH_DEFAULTS };
if (!raw || typeof raw !== "object") return base;
const obj = raw as Record<string, unknown>;
for (const key of SFTP_COL_KEYS) {
const v = obj[key];
if (typeof v === "number" || typeof v === "string") {
base[key] = clampSftpColWidth(key, Number(v));
}
}
return base;
}
function loadSftpColWidths(): SftpColWidths {
try {
const raw = localStorage.getItem(SFTP_COL_WIDTHS_KEY);
if (!raw) return { ...SFTP_COL_WIDTH_DEFAULTS };
return normalizeSftpColWidths(JSON.parse(raw));
} catch {
return { ...SFTP_COL_WIDTH_DEFAULTS };
}
}
function saveSftpColWidths(widths: SftpColWidths): void {
try {
localStorage.setItem(SFTP_COL_WIDTHS_KEY, JSON.stringify(widths));
} catch {
/* ignore */
}
}
function sumSftpColWidths(widths: SftpColWidths): number {
return SFTP_COL_KEYS.reduce((acc, key) => acc + widths[key], 0);
}
function formatSftpSizeKb(bytes: number, isDir: boolean): string {
if (isDir) return "";
const kb = Number(bytes || 0) / 1024;
@ -417,6 +489,80 @@ function formatSftpMtime(sec: number): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatSftpTransferPct(loaded: number, total: number): string {
const t = Number(total || 0);
if (!Number.isFinite(t) || t <= 0) return "";
const pct = Math.min(100, Math.max(0, Math.round((Number(loaded || 0) / t) * 100)));
return `${pct}%`;
}
type SftpUploadItem = { file: File; relativePath: string };
type FsEntryLike = {
isFile: boolean;
isDirectory: boolean;
name: string;
file: (ok: (f: File) => void, err?: (e: DOMException) => void) => void;
createReader: () => {
readEntries: (ok: (entries: FsEntryLike[]) => void, err?: (e: DOMException) => void) => void;
};
};
async function readAllDirectoryEntries(
reader: ReturnType<FsEntryLike["createReader"]>,
): Promise<FsEntryLike[]> {
const all: FsEntryLike[] = [];
for (;;) {
const batch = await new Promise<FsEntryLike[]>((resolve, reject) => {
reader.readEntries(resolve, reject);
});
if (!batch.length) break;
all.push(...batch);
}
return all;
}
async function walkFsEntry(entry: FsEntryLike, prefix: string, out: SftpUploadItem[]): Promise<void> {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => {
entry.file(resolve, reject);
});
const relativePath = (prefix ? `${prefix}/${entry.name}` : entry.name).replace(/\\/g, "/");
out.push({ file, relativePath });
return;
}
if (!entry.isDirectory) return;
const nextPrefix = prefix ? `${prefix}/${entry.name}` : entry.name;
const children = await readAllDirectoryEntries(entry.createReader());
for (const child of children) {
await walkFsEntry(child, nextPrefix, out);
}
}
async function collectDroppedSftpUploads(dt: DataTransfer): Promise<SftpUploadItem[]> {
const out: SftpUploadItem[] = [];
const items = Array.from(dt.items || []);
const entries = items
.map((it) => {
const getter = (it as DataTransferItem & { webkitGetAsEntry?: () => FileSystemEntry | null }).webkitGetAsEntry;
return typeof getter === "function" ? (getter.call(it) as FsEntryLike | null) : null;
})
.filter((e): e is FsEntryLike => Boolean(e));
if (entries.length) {
for (const entry of entries) {
await walkFsEntry(entry, "", out);
}
return out;
}
for (const file of Array.from(dt.files || [])) {
const rel = String((file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name)
.replace(/\\/g, "/")
.replace(/^\/+/, "");
out.push({ file, relativePath: rel || file.name });
}
return out;
}
function connectFailedNe(err: unknown): ManagedNeItem | null {
if (!(err instanceof ApiRequestError)) return null;
const detail = err.detail;
@ -445,6 +591,7 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string,
}
if (raw.includes("sftp_hop_not_supported")) return t("webcrt.err.sftpHop");
if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh");
if (raw.includes("sftp_file_too_large")) return t("webcrt.err.sftpTooLarge");
if (raw.includes("websocket_error")) return t("webcrt.err.websocket");
return raw;
}
@ -495,9 +642,14 @@ export function WebcrtPage() {
const [sftpItems, setSftpItems] = useState<WebcrtSftpItem[]>([]);
const [sftpWidth, setSftpWidth] = useState(() => loadSftpWidth());
const [sftpResizing, setSftpResizing] = useState(false);
const [sftpColWidths, setSftpColWidths] = useState<SftpColWidths>(() => loadSftpColWidths());
const [sftpDragOver, setSftpDragOver] = useState(false);
const [sftpStatus, setSftpStatus] = useState("");
const sftpPathRef = useRef(".");
const sftpBodyRef = useRef<HTMLDivElement | null>(null);
const sftpDragRef = useRef<{ startX: number; startW: number } | null>(null);
const sftpColDragRef = useRef<{ key: SftpColKey; startX: number; startW: number } | null>(null);
const sftpDragDepthRef = useRef(0);
sftpPathRef.current = sftpPath;
const [sessionOpts, setSessionOpts] = useState<SessionOptions>(() => loadSessionOptions());
const [optionsMenuOpen, setOptionsMenuOpen] = useState(false);
@ -1163,6 +1315,118 @@ export function WebcrtPage() {
[refreshSftp],
);
const uploadSftpItems = useCallback(
async (items: SftpUploadItem[]) => {
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab || !items.length) return;
setSftpBusy(true);
setSftpDragOver(false);
sftpDragDepthRef.current = 0;
let done = 0;
let failed = 0;
try {
for (const item of items) {
done += 1;
const shortName = String(item.relativePath || item.file.name).split("/").pop() || item.file.name;
setSftpStatus(
t("webcrt.sftp.uploading", { done, total: items.length, name: shortName, pct: "0%" }),
);
const rel = String(item.relativePath || item.file.name)
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+/g, "/");
if (!rel || rel.includes("..")) {
failed += 1;
continue;
}
const remote = joinSftpPath(sftpPathRef.current, rel);
try {
const body =
tab.target.source === "ume"
? {
ume_ne_id: tab.target.ume_ne_id || tab.target.id,
remote_path: remote,
file: item.file,
}
: { ne_id: tab.target.id, remote_path: remote, file: item.file };
await webcrtSftpUpload(body, (p) => {
const pct = formatSftpTransferPct(p.loaded, p.total || item.file.size);
setSftpStatus(
t("webcrt.sftp.uploading", {
done,
total: items.length,
name: shortName,
pct: pct || "…",
}),
);
});
} catch (err) {
failed += 1;
if (items.length === 1) throw err;
}
}
await refreshSftp(sftpPathRef.current);
if (failed > 0) {
showError(t("webcrt.sftp.uploadPartial", { ok: items.length - failed, failed }));
} else {
showOk(t("webcrt.sftp.uploaded"));
}
} catch (err) {
showError(webcrtErrorMessage(err, t));
} finally {
setSftpBusy(false);
setSftpStatus("");
}
},
[activeTabKey, refreshSftp, showError, showOk, t],
);
const onSftpDragEnter = useCallback((e: ReactDragEvent<HTMLElement>) => {
if (![...e.dataTransfer.types].includes("Files")) return;
e.preventDefault();
e.stopPropagation();
sftpDragDepthRef.current += 1;
setSftpDragOver(true);
}, []);
const onSftpDragOver = useCallback((e: ReactDragEvent<HTMLElement>) => {
if (![...e.dataTransfer.types].includes("Files")) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}, []);
const onSftpDragLeave = useCallback((e: ReactDragEvent<HTMLElement>) => {
if (![...e.dataTransfer.types].includes("Files")) return;
e.preventDefault();
e.stopPropagation();
sftpDragDepthRef.current = Math.max(0, sftpDragDepthRef.current - 1);
if (sftpDragDepthRef.current === 0) setSftpDragOver(false);
}, []);
const onSftpDrop = useCallback(
(e: ReactDragEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
sftpDragDepthRef.current = 0;
setSftpDragOver(false);
if (sftpBusy) return;
void (async () => {
try {
const items = await collectDroppedSftpUploads(e.dataTransfer);
if (!items.length) {
showError(t("webcrt.sftp.dropEmpty"));
return;
}
await uploadSftpItems(items);
} catch (err) {
showError(webcrtErrorMessage(err, t));
}
})();
},
[showError, sftpBusy, t, uploadSftpItems],
);
const onSftpSplitDown = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
sftpDragRef.current = { startX: e.clientX, startW: sftpWidth };
@ -1193,6 +1457,33 @@ export function WebcrtPage() {
document.addEventListener("mouseup", onUp);
}, [sftpWidth]);
const onSftpColResizeDown = useCallback((key: SftpColKey, e: ReactMouseEvent<HTMLSpanElement>) => {
e.preventDefault();
e.stopPropagation();
sftpColDragRef.current = { key, startX: e.clientX, startW: sftpColWidths[key] };
const onMove = (ev: MouseEvent) => {
const drag = sftpColDragRef.current;
if (!drag) return;
const next = clampSftpColWidth(drag.key, drag.startW + (ev.clientX - drag.startX));
setSftpColWidths((prev) => (prev[drag.key] === next ? prev : { ...prev, [drag.key]: next }));
};
const onUp = () => {
sftpColDragRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setSftpColWidths((prev) => {
saveSftpColWidths(prev);
return prev;
});
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, [sftpColWidths]);
// Auto-connect from /webcrt?ne_id=...
useEffect(() => {
if (!presetNeId) return;
@ -1744,8 +2035,12 @@ export function WebcrtPage() {
onMouseDown={onSftpSplitDown}
/>
<aside
className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}`}
className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}${sftpDragOver ? " is-drop-target" : ""}`}
style={{ width: sftpWidth }}
onDragEnter={onSftpDragEnter}
onDragOver={onSftpDragOver}
onDragLeave={onSftpDragLeave}
onDrop={onSftpDrop}
>
<div className="webcrt-sftp__bar">
<button
@ -1779,51 +2074,65 @@ export function WebcrtPage() {
<input
type="file"
hidden
multiple
disabled={sftpBusy}
onChange={(e) => {
const file = e.target.files?.[0];
if (!file || !activeTab || sftpBusy) return;
const remote = joinSftpPath(sftpPathRef.current, file.name);
void (async () => {
setSftpBusy(true);
try {
const body =
activeTab.target.source === "ume"
? {
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
remote_path: remote,
file,
}
: { ne_id: activeTab.target.id, remote_path: remote, file };
await webcrtSftpUpload(body);
showOk(t("webcrt.sftp.uploaded"));
await refreshSftp(sftpPathRef.current);
} catch (err) {
showError(webcrtErrorMessage(err, t));
} finally {
setSftpBusy(false);
}
})();
const files = Array.from(e.target.files || []);
e.target.value = "";
if (!files.length || sftpBusy) return;
void uploadSftpItems(files.map((file) => ({ file, relativePath: file.name })));
}}
/>
</label>
</div>
{sftpBusy ? (
{sftpBusy || sftpStatus ? (
<div className="webcrt-sftp__status" role="status">
{t("webcrt.sftp.loading")}
{sftpStatus || t("webcrt.sftp.loading")}
</div>
) : null}
{sftpDragOver ? (
<div className="webcrt-sftp__drop-overlay" aria-hidden>
{t("webcrt.sftp.dropHint")}
</div>
) : null}
<div className="webcrt-sftp__table-wrap">
<table className="webcrt-sftp__table">
<table
className="webcrt-sftp__table"
style={{
width: sumSftpColWidths(sftpColWidths),
minWidth: sumSftpColWidths(sftpColWidths),
}}
>
<colgroup>
{SFTP_COL_KEYS.map((key) => (
<col key={key} style={{ width: sftpColWidths[key] }} />
))}
</colgroup>
<thead>
<tr>
<th className="webcrt-sftp__col-name">{t("webcrt.sftp.colName")}</th>
<th className="webcrt-sftp__col-size">{t("webcrt.sftp.colSize")}</th>
<th className="webcrt-sftp__col-mtime">{t("webcrt.sftp.colMtime")}</th>
<th className="webcrt-sftp__col-owner">{t("webcrt.sftp.colOwner")}</th>
<th className="webcrt-sftp__col-group">{t("webcrt.sftp.colGroup")}</th>
<th className="webcrt-sftp__col-mode">{t("webcrt.sftp.colMode")}</th>
{(
[
["name", "webcrt.sftp.colName"],
["size", "webcrt.sftp.colSize"],
["mtime", "webcrt.sftp.colMtime"],
["owner", "webcrt.sftp.colOwner"],
["group", "webcrt.sftp.colGroup"],
["mode", "webcrt.sftp.colMode"],
] as const
).map(([key, labelKey]) => (
<th
key={key}
className={`webcrt-sftp__col-${key}`}
style={{ width: sftpColWidths[key] }}
>
<span className="webcrt-sftp__th-label">{t(labelKey)}</span>
<span
className="webcrt-sftp__col-resizer"
title={t("webcrt.sftp.colResizeHint")}
onMouseDown={(e) => onSftpColResizeDown(key, e)}
/>
</th>
))}
</tr>
</thead>
<tbody>
@ -1837,6 +2146,9 @@ export function WebcrtPage() {
const remote = joinSftpPath(sftpPathRef.current, it.name);
void (async () => {
setSftpBusy(true);
setSftpStatus(
t("webcrt.sftp.downloading", { name: it.name, pct: "0%" }),
);
try {
const body =
activeTab.target.source === "ume"
@ -1845,7 +2157,15 @@ export function WebcrtPage() {
path: remote,
}
: { ne_id: activeTab.target.id, path: remote };
const blob = await webcrtSftpDownload(body);
const blob = await webcrtSftpDownload(body, (p) => {
const pct = formatSftpTransferPct(p.loaded, p.total || it.size);
setSftpStatus(
t("webcrt.sftp.downloading", {
name: it.name,
pct: pct || "…",
}),
);
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@ -1856,6 +2176,7 @@ export function WebcrtPage() {
showError(webcrtErrorMessage(err, t));
} finally {
setSftpBusy(false);
setSftpStatus("");
}
})();
}}
@ -1864,7 +2185,7 @@ export function WebcrtPage() {
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
}}
>
<td className="webcrt-sftp__col-name">
<td className="webcrt-sftp__col-name" style={{ width: sftpColWidths.name }}>
<span className="webcrt-sftp__name-cell">
<span className={`webcrt-sftp__icon${it.is_dir ? " is-dir" : ""}`}>
{it.is_dir ? <FolderIcon /> : <FileIcon />}
@ -1872,13 +2193,21 @@ export function WebcrtPage() {
<span className="webcrt-sftp__name">{it.name}</span>
</span>
</td>
<td className="webcrt-sftp__col-size">
<td className="webcrt-sftp__col-size" style={{ width: sftpColWidths.size }}>
{formatSftpSizeKb(it.size, it.is_dir)}
</td>
<td className="webcrt-sftp__col-mtime">{formatSftpMtime(it.mtime)}</td>
<td className="webcrt-sftp__col-owner">{it.owner || ""}</td>
<td className="webcrt-sftp__col-group">{it.group || ""}</td>
<td className="webcrt-sftp__col-mode">{it.mode || ""}</td>
<td className="webcrt-sftp__col-mtime" style={{ width: sftpColWidths.mtime }}>
{formatSftpMtime(it.mtime)}
</td>
<td className="webcrt-sftp__col-owner" style={{ width: sftpColWidths.owner }}>
{it.owner || ""}
</td>
<td className="webcrt-sftp__col-group" style={{ width: sftpColWidths.group }}>
{it.group || ""}
</td>
<td className="webcrt-sftp__col-mode" style={{ width: sftpColWidths.mode }}>
{it.mode || ""}
</td>
</tr>
))}
</tbody>

View file

@ -568,11 +568,19 @@ export type WebcrtSftpListResult = {
export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>
apiPost<WebcrtSftpListResult>("/v1/webcrt/sftp/list", body);
export async function webcrtSftpDownload(body: {
ne_id?: string;
ume_ne_id?: string;
path: string;
}): Promise<Blob> {
export type WebcrtSftpTransferProgress = {
loaded: number;
total: number;
};
export async function webcrtSftpDownload(
body: {
ne_id?: string;
ume_ne_id?: string;
path: string;
},
onProgress?: (p: WebcrtSftpTransferProgress) => void,
): Promise<Blob> {
const path = "/v1/webcrt/sftp/download";
const res = await fetch(path, {
method: "POST",
@ -587,35 +595,76 @@ export async function webcrtSftpDownload(body: {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return res.blob();
const total = Number(res.headers.get("content-length") || 0);
if (!res.body) {
const blob = await res.blob();
onProgress?.({ loaded: blob.size, total: total || blob.size });
return blob;
}
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let loaded = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
loaded += value.byteLength;
onProgress?.({ loaded, total });
}
}
return new Blob(chunks as BlobPart[], { type: "application/octet-stream" });
}
export async function webcrtSftpUpload(body: {
ne_id?: string;
ume_ne_id?: string;
remote_path: string;
file: File;
}): Promise<{ ok: boolean; path: string; size: number }> {
export function webcrtSftpUpload(
body: {
ne_id?: string;
ume_ne_id?: string;
remote_path: string;
file: File;
},
onProgress?: (p: WebcrtSftpTransferProgress) => void,
): Promise<{ ok: boolean; path: string; size: number }> {
const path = "/v1/webcrt/sftp/upload";
const fd = new FormData();
if (body.ne_id) fd.append("ne_id", body.ne_id);
if (body.ume_ne_id) fd.append("ume_ne_id", body.ume_ne_id);
fd.append("remote_path", body.remote_path);
fd.append("file", body.file);
const res = await fetch(path, {
method: "POST",
headers: authHeaders(),
body: fd,
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", path);
const tok = getAuthToken();
if (tok) xhr.setRequestHeader("Authorization", `Bearer ${tok}`);
xhr.responseType = "text";
xhr.upload.onprogress = (ev) => {
if (!onProgress) return;
onProgress({
loaded: Number(ev.loaded || 0),
total: ev.lengthComputable ? Number(ev.total || 0) : body.file.size || 0,
});
};
xhr.onload = () => {
if (xhr.status === 401) {
handleUnauthorized(path);
reject(new Error("401 unauthorized"));
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(xhr.responseText || `HTTP ${xhr.status}`));
return;
}
try {
resolve(JSON.parse(xhr.responseText || "{}") as { ok: boolean; path: string; size: number });
} catch {
reject(new Error("invalid_upload_response"));
}
};
xhr.onerror = () => reject(new Error("network_error"));
xhr.onabort = () => reject(new Error("aborted"));
xhr.send(fd);
});
if (res.status === 401) {
handleUnauthorized(path);
throw new Error("401 unauthorized");
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return (await res.json()) as { ok: boolean; path: string; size: number };
}
export const webcrtWsUrl = (sessionId: string): string => {