mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
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:
parent
7d8b698b91
commit
d10ffed277
9 changed files with 769 additions and 120 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,23 +244,32 @@ 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(
|
||||
|
||||
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,
|
||||
data=content,
|
||||
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")
|
||||
async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) -> tuple[bytes, str]:
|
||||
remote = _normalize_remote(path, allow_dot=False)
|
||||
if not remote or remote.endswith("/"):
|
||||
) -> 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:
|
||||
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:
|
||||
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")
|
||||
return data, posixpath.basename(remote) or "download.bin"
|
||||
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,
|
||||
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):
|
||||
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""),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 连接失败",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
export type WebcrtSftpTransferProgress = {
|
||||
loaded: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export async function webcrtSftpDownload(
|
||||
body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
path: string;
|
||||
}): Promise<Blob> {
|
||||
},
|
||||
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: {
|
||||
export function webcrtSftpUpload(
|
||||
body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
remote_path: string;
|
||||
file: File;
|
||||
}): Promise<{ ok: boolean; path: string; size: number }> {
|
||||
},
|
||||
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,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("401 unauthorized");
|
||||
reject(new Error("401 unauthorized"));
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
reject(new Error(xhr.responseText || `HTTP ${xhr.status}`));
|
||||
return;
|
||||
}
|
||||
return (await res.json()) as { ok: boolean; path: string; size: number };
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
export const webcrtWsUrl = (sessionId: string): string => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue