mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 22:20:58 +08:00
Expand WebCRT SFTP into a fuller file manager.
Add mkdir/rename/delete/chmod, multi-select batch ops, cancelable transfers with retries, per-tab paths, sorting, and safer listing limits. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d10ffed277
commit
9674de221d
10 changed files with 1146 additions and 153 deletions
|
|
@ -104,6 +104,9 @@ class Settings(BaseSettings):
|
|||
# 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
|
||||
# Cap directory listings so huge folders cannot pin the API/UI.
|
||||
webcrt_sftp_list_max_entries: int = 5000
|
||||
webcrt_sftp_list_timeout_sec: float = 30.0
|
||||
# Local app login / audit (lab defaults; override in production)
|
||||
auth_enabled: bool = True
|
||||
# Stable default so JWT survives restarts without .env. Override in production.
|
||||
|
|
|
|||
|
|
@ -80,6 +80,33 @@ class WebcrtSftpDownloadBody(BaseModel):
|
|||
path: str
|
||||
|
||||
|
||||
class WebcrtSftpMkdirBody(BaseModel):
|
||||
ne_id: str | None = Field(default=None)
|
||||
ume_ne_id: str | None = Field(default=None)
|
||||
path: str
|
||||
|
||||
|
||||
class WebcrtSftpRemoveBody(BaseModel):
|
||||
ne_id: str | None = Field(default=None)
|
||||
ume_ne_id: str | None = Field(default=None)
|
||||
path: str
|
||||
recursive: bool = False
|
||||
|
||||
|
||||
class WebcrtSftpRenameBody(BaseModel):
|
||||
ne_id: str | None = Field(default=None)
|
||||
ume_ne_id: str | None = Field(default=None)
|
||||
old_path: str
|
||||
new_path: str
|
||||
|
||||
|
||||
class WebcrtSftpChmodBody(BaseModel):
|
||||
ne_id: str | None = Field(default=None)
|
||||
ume_ne_id: str | None = Field(default=None)
|
||||
path: str
|
||||
mode: str
|
||||
|
||||
|
||||
def _client_label(request: Request | None = None, websocket: WebSocket | None = None) -> str:
|
||||
host = ""
|
||||
if request is not None:
|
||||
|
|
@ -200,15 +227,64 @@ def api_close_session(session_id: str, request: Request) -> dict[str, Any]:
|
|||
return close_session(session_id, reason="client_delete", client=_client_label(request=request))
|
||||
|
||||
|
||||
def _sftp_ne_ids(ne_id: str | None, ume_ne_id: str | None) -> tuple[str | None, str | None]:
|
||||
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")
|
||||
return (mid or None, uid or None)
|
||||
|
||||
|
||||
@router.post("/sftp/list")
|
||||
def api_sftp_list(body: WebcrtSftpListBody, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
from .webcrt_sftp import sftp_list
|
||||
|
||||
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")
|
||||
return sftp_list(db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path)
|
||||
mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id)
|
||||
return sftp_list(db, managed_ne_id=mid, ume_ne_id=uid, path=body.path)
|
||||
|
||||
|
||||
@router.post("/sftp/mkdir")
|
||||
def api_sftp_mkdir(body: WebcrtSftpMkdirBody, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
from .webcrt_sftp import sftp_mkdir
|
||||
|
||||
mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id)
|
||||
return sftp_mkdir(db, managed_ne_id=mid, ume_ne_id=uid, path=body.path)
|
||||
|
||||
|
||||
@router.post("/sftp/remove")
|
||||
def api_sftp_remove(body: WebcrtSftpRemoveBody, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
from .webcrt_sftp import sftp_remove
|
||||
|
||||
mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id)
|
||||
return sftp_remove(
|
||||
db,
|
||||
managed_ne_id=mid,
|
||||
ume_ne_id=uid,
|
||||
path=body.path,
|
||||
recursive=bool(body.recursive),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sftp/rename")
|
||||
def api_sftp_rename(body: WebcrtSftpRenameBody, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
from .webcrt_sftp import sftp_rename
|
||||
|
||||
mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id)
|
||||
return sftp_rename(
|
||||
db,
|
||||
managed_ne_id=mid,
|
||||
ume_ne_id=uid,
|
||||
old_path=body.old_path,
|
||||
new_path=body.new_path,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sftp/chmod")
|
||||
def api_sftp_chmod(body: WebcrtSftpChmodBody, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
from .webcrt_sftp import sftp_chmod
|
||||
|
||||
mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id)
|
||||
return sftp_chmod(db, managed_ne_id=mid, ume_ne_id=uid, path=body.path, mode=body.mode)
|
||||
|
||||
|
||||
@router.post("/sftp/download")
|
||||
|
|
@ -217,13 +293,8 @@ def api_sftp_download(body: WebcrtSftpDownloadBody, db: Session = Depends(get_db
|
|||
|
||||
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")
|
||||
stream = SftpDownloadStream(
|
||||
db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path
|
||||
).open()
|
||||
mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id)
|
||||
stream = SftpDownloadStream(db, managed_ne_id=mid, ume_ne_id=uid, path=body.path).open()
|
||||
headers = {
|
||||
"Content-Disposition": stream.content_disposition(),
|
||||
"Content-Length": str(int(stream.size)),
|
||||
|
|
@ -249,10 +320,7 @@ async def api_sftp_upload(
|
|||
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")
|
||||
mid, uid = _sftp_ne_ids(ne_id, ume_ne_id)
|
||||
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:
|
||||
|
|
@ -261,8 +329,8 @@ async def api_sftp_upload(
|
|||
def _upload() -> dict[str, Any]:
|
||||
return sftp_upload_stream(
|
||||
db,
|
||||
managed_ne_id=mid or None,
|
||||
ume_ne_id=uid or None,
|
||||
managed_ne_id=mid,
|
||||
ume_ne_id=uid,
|
||||
remote_path=remote_path,
|
||||
reader=file.file,
|
||||
expected_size=int(expected) if expected is not None else None,
|
||||
|
|
|
|||
|
|
@ -519,25 +519,14 @@ class WebcrtSession:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def _ensure_sftp_unlocked(self) -> Any:
|
||||
"""Caller must hold ``_sftp_lock``."""
|
||||
import paramiko
|
||||
|
||||
def _ssh_transport_unlocked(self) -> Any:
|
||||
"""Caller must hold ``_sftp_lock``. Returns an active Paramiko Transport."""
|
||||
if self.closed or self.conn is None:
|
||||
raise RuntimeError("session_closed")
|
||||
if str(self.protocol or "ssh").lower() != "ssh":
|
||||
raise RuntimeError("sftp_requires_ssh")
|
||||
if self.cli_hop_guard:
|
||||
raise RuntimeError("sftp_hop_not_supported")
|
||||
if self._sftp is not None:
|
||||
sock = getattr(self._sftp, "sock", None)
|
||||
if sock is not None and not bool(getattr(sock, "closed", False)):
|
||||
return self._sftp
|
||||
try:
|
||||
self._sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._sftp = None
|
||||
channel = getattr(self.conn, "remote_conn", None)
|
||||
transport = None
|
||||
if channel is not None and hasattr(channel, "get_transport"):
|
||||
|
|
@ -547,6 +536,22 @@ class WebcrtSession:
|
|||
transport = None
|
||||
if transport is None or not bool(getattr(transport, "is_active", lambda: False)()):
|
||||
raise RuntimeError("ssh_transport_unavailable")
|
||||
return transport
|
||||
|
||||
def _ensure_sftp_unlocked(self) -> Any:
|
||||
"""Caller must hold ``_sftp_lock``. Shared probe client (sftp_ready)."""
|
||||
import paramiko
|
||||
|
||||
if self._sftp is not None:
|
||||
sock = getattr(self._sftp, "sock", None)
|
||||
if sock is not None and not bool(getattr(sock, "closed", False)):
|
||||
return self._sftp
|
||||
try:
|
||||
self._sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._sftp = None
|
||||
transport = self._ssh_transport_unlocked()
|
||||
self._sftp = paramiko.SFTPClient.from_transport(transport)
|
||||
if self._sftp is None:
|
||||
raise RuntimeError("sftp_open_failed")
|
||||
|
|
@ -558,11 +563,36 @@ class WebcrtSession:
|
|||
with self._sftp_lock:
|
||||
return self._ensure_sftp_unlocked()
|
||||
|
||||
def run_sftp(self, fn: Any) -> Any:
|
||||
"""Run ``fn(sftp)`` while holding the session SFTP lock."""
|
||||
def open_ephemeral_sftp(self) -> Any:
|
||||
"""Open a dedicated SFTP channel for one operation; caller must ``close()`` it.
|
||||
|
||||
Only holds ``_sftp_lock`` briefly while resolving the SSH transport, so long
|
||||
list/upload/download work does not block other SFTP ops on the same session.
|
||||
"""
|
||||
import paramiko
|
||||
|
||||
with self._sftp_lock:
|
||||
sftp = self._ensure_sftp_unlocked()
|
||||
transport = self._ssh_transport_unlocked()
|
||||
# Keep probe client warm for UI sftp_ready without sharing it for I/O.
|
||||
try:
|
||||
self._ensure_sftp_unlocked()
|
||||
except Exception:
|
||||
pass
|
||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||
if sftp is None:
|
||||
raise RuntimeError("sftp_open_failed")
|
||||
return sftp
|
||||
|
||||
def run_sftp(self, fn: Any) -> Any:
|
||||
"""Run ``fn(sftp)`` on an ephemeral channel (does not hold the lock during ``fn``)."""
|
||||
sftp = self.open_ephemeral_sftp()
|
||||
try:
|
||||
return fn(sftp)
|
||||
finally:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def try_attach_sftp(self) -> bool:
|
||||
"""Best-effort SFTP channel open after SSH login (does not fail the shell)."""
|
||||
|
|
|
|||
|
|
@ -150,39 +150,65 @@ def _sftp_client(
|
|||
managed_ne_id: str | None,
|
||||
ume_ne_id: str | None,
|
||||
) -> Iterator[tuple[Any, dict[str, Any]]]:
|
||||
"""Yield ``(sftp, device)`` — prefers live WebCRT SSH session channel."""
|
||||
"""Yield ``(sftp, device)`` — prefers a short-lived channel on the live SSH session."""
|
||||
creds, device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
|
||||
ne_key = str(device.get("id") or managed_ne_id or ume_ne_id or "").strip()
|
||||
sess = find_ssh_session_for_ne(ne_key) if ne_key else None
|
||||
if sess is not None:
|
||||
opened = False
|
||||
sftp = None
|
||||
try:
|
||||
with sess._sftp_lock:
|
||||
sftp = sess._ensure_sftp_unlocked()
|
||||
opened = True
|
||||
yield sftp, device
|
||||
sftp = sess.open_ephemeral_sftp()
|
||||
yield sftp, device
|
||||
return
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if opened:
|
||||
# Operation failed on an already-open session channel — don't double-yield.
|
||||
if sftp is not None:
|
||||
raise HTTPException(status_code=502, detail=f"sftp_failed:{exc}") from exc
|
||||
_log.debug("session sftp open failed ne=%s: %s — pool fallback", ne_key, exc)
|
||||
finally:
|
||||
if sftp is not None:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
key = _pool_key(managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
|
||||
entry = _get_pooled(key, creds)
|
||||
# Borrow transport under a short lock, then use a dedicated SFTP channel for I/O.
|
||||
with entry.lock:
|
||||
entry.last_used = time.time()
|
||||
transport = None
|
||||
try:
|
||||
yield entry.sftp, device
|
||||
transport = entry.client.get_transport()
|
||||
except Exception:
|
||||
# Drop broken pooled socket so the next call reconnects once.
|
||||
transport = None
|
||||
if transport is None or not bool(getattr(transport, "is_active", lambda: False)()):
|
||||
with _pool_lock:
|
||||
cur = _pool.pop(key, None)
|
||||
if cur is not None:
|
||||
_close_pooled(cur)
|
||||
raise
|
||||
raise HTTPException(status_code=502, detail="sftp_pool_transport_unavailable")
|
||||
sftp = None
|
||||
try:
|
||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||
if sftp is None:
|
||||
raise HTTPException(status_code=502, detail="sftp_open_failed")
|
||||
yield sftp, device
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
with _pool_lock:
|
||||
cur = _pool.pop(key, None)
|
||||
if cur is not None:
|
||||
_close_pooled(cur)
|
||||
raise
|
||||
finally:
|
||||
if sftp is not None:
|
||||
try:
|
||||
sftp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _filemode(mode: int) -> str:
|
||||
|
|
@ -239,6 +265,188 @@ def _mkdir_p(sftp: Any, remote: str) -> None:
|
|||
raise HTTPException(status_code=502, detail=f"sftp_mkdir_failed:{candidate}:{exc}") from exc
|
||||
|
||||
|
||||
def _rmtree(sftp: Any, remote: str) -> None:
|
||||
path = _normalize_remote(remote, allow_dot=False)
|
||||
if not path or path in (".", "/"):
|
||||
raise HTTPException(status_code=400, detail="sftp_path_required")
|
||||
try:
|
||||
for attr in sftp.listdir_attr(path):
|
||||
name = str(getattr(attr, "filename", "") or "")
|
||||
if not name or name in (".", ".."):
|
||||
continue
|
||||
child = posixpath.join(path, name)
|
||||
mode = int(getattr(attr, "st_mode", 0) or 0)
|
||||
if statmod.S_ISDIR(mode):
|
||||
_rmtree(sftp, child)
|
||||
else:
|
||||
sftp.remove(child)
|
||||
sftp.rmdir(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"sftp_remove_failed:{exc}") from exc
|
||||
|
||||
|
||||
def sftp_mkdir(
|
||||
db: Session,
|
||||
*,
|
||||
managed_ne_id: str | None,
|
||||
ume_ne_id: str | None,
|
||||
path: str,
|
||||
) -> dict[str, Any]:
|
||||
remote = _normalize_remote(path, allow_dot=False)
|
||||
if not remote or remote in (".", "/"):
|
||||
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):
|
||||
_mkdir_p(sftp, remote)
|
||||
return {
|
||||
"ok": True,
|
||||
"ne_id": str(device.get("id") or ""),
|
||||
"path": remote,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"sftp_mkdir_failed:{exc}") from exc
|
||||
|
||||
|
||||
def sftp_remove(
|
||||
db: Session,
|
||||
*,
|
||||
managed_ne_id: str | None,
|
||||
ume_ne_id: str | None,
|
||||
path: str,
|
||||
recursive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
remote = _normalize_remote(path, allow_dot=False)
|
||||
if not remote or remote in (".", "/"):
|
||||
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):
|
||||
try:
|
||||
st = sftp.stat(remote)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail=f"sftp_not_found:{exc}") from exc
|
||||
mode = int(getattr(st, "st_mode", 0) or 0)
|
||||
if statmod.S_ISDIR(mode):
|
||||
if recursive:
|
||||
_rmtree(sftp, remote)
|
||||
else:
|
||||
try:
|
||||
sftp.rmdir(remote)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=409, detail="sftp_dir_not_empty") from exc
|
||||
else:
|
||||
sftp.remove(remote)
|
||||
return {
|
||||
"ok": True,
|
||||
"ne_id": str(device.get("id") or ""),
|
||||
"path": remote,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"sftp_remove_failed:{exc}") from exc
|
||||
|
||||
|
||||
def sftp_rename(
|
||||
db: Session,
|
||||
*,
|
||||
managed_ne_id: str | None,
|
||||
ume_ne_id: str | None,
|
||||
old_path: str,
|
||||
new_path: str,
|
||||
) -> dict[str, Any]:
|
||||
src = _normalize_remote(old_path, allow_dot=False)
|
||||
dst = _normalize_remote(new_path, allow_dot=False)
|
||||
if not src or not dst or src in (".", "/") or dst in (".", "/"):
|
||||
raise HTTPException(status_code=400, detail="sftp_path_required")
|
||||
if src == dst:
|
||||
return {"ok": True, "ne_id": "", "old_path": src, "new_path": dst}
|
||||
try:
|
||||
with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device):
|
||||
parent = posixpath.dirname(dst)
|
||||
if parent and parent not in (".", "/"):
|
||||
_mkdir_p(sftp, parent)
|
||||
try:
|
||||
sftp.rename(src, dst)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"sftp_rename_failed:{exc}") from exc
|
||||
return {
|
||||
"ok": True,
|
||||
"ne_id": str(device.get("id") or ""),
|
||||
"old_path": src,
|
||||
"new_path": dst,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"sftp_rename_failed:{exc}") from exc
|
||||
|
||||
|
||||
def _parse_chmod_mode(mode: str | int) -> int:
|
||||
if isinstance(mode, int):
|
||||
return int(mode) & 0o7777
|
||||
raw = str(mode or "").strip().lower()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="sftp_chmod_invalid_mode")
|
||||
if raw.startswith("0o"):
|
||||
raw = raw[2:]
|
||||
if raw.isdigit():
|
||||
try:
|
||||
return int(raw, 8) & 0o7777
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="sftp_chmod_invalid_mode") from exc
|
||||
sym = raw.lstrip("d").lstrip("-")
|
||||
if len(sym) != 9 or any(c not in "rwx-" for c in sym):
|
||||
raise HTTPException(status_code=400, detail="sftp_chmod_invalid_mode")
|
||||
bits = {"r": 4, "w": 2, "x": 1, "-": 0}
|
||||
value = 0
|
||||
for i in range(3):
|
||||
trip = sym[i * 3 : (i + 1) * 3]
|
||||
value = (value << 3) | (bits[trip[0]] + bits[trip[1]] + bits[trip[2]])
|
||||
return value & 0o7777
|
||||
|
||||
|
||||
def sftp_chmod(
|
||||
db: Session,
|
||||
*,
|
||||
managed_ne_id: str | None,
|
||||
ume_ne_id: str | None,
|
||||
path: str,
|
||||
mode: str | int,
|
||||
) -> dict[str, Any]:
|
||||
remote = _normalize_remote(path, allow_dot=False)
|
||||
if not remote or remote in (".", "/"):
|
||||
raise HTTPException(status_code=400, detail="sftp_path_required")
|
||||
mode_int = _parse_chmod_mode(mode)
|
||||
try:
|
||||
with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device):
|
||||
try:
|
||||
sftp.chmod(remote, mode_int)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"sftp_chmod_failed:{exc}") from exc
|
||||
return {
|
||||
"ok": True,
|
||||
"ne_id": str(device.get("id") or ""),
|
||||
"path": remote,
|
||||
"mode": f"{mode_int:04o}",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"sftp_chmod_failed:{exc}") from exc
|
||||
|
||||
|
||||
def _list_max_entries() -> int:
|
||||
return max(100, int(settings.webcrt_sftp_list_max_entries or 5000))
|
||||
|
||||
|
||||
def _list_timeout_sec() -> float:
|
||||
return max(1.0, float(settings.webcrt_sftp_list_timeout_sec or 30.0))
|
||||
|
||||
|
||||
def sftp_list(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -247,10 +455,17 @@ def sftp_list(
|
|||
path: str = ".",
|
||||
) -> dict[str, Any]:
|
||||
remote = _normalize_remote(path, allow_dot=True) or "."
|
||||
max_entries = _list_max_entries()
|
||||
timeout_sec = _list_timeout_sec()
|
||||
try:
|
||||
with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device):
|
||||
entries = []
|
||||
truncated = False
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
for attr in sftp.listdir_attr(remote):
|
||||
if time.monotonic() > deadline:
|
||||
truncated = True
|
||||
break
|
||||
mode = int(getattr(attr, "st_mode", 0) or 0)
|
||||
name = str(attr.filename or "")
|
||||
if not name or name in (".", ".."):
|
||||
|
|
@ -269,12 +484,17 @@ def sftp_list(
|
|||
"gid": int(getattr(attr, "st_gid", 0) or 0),
|
||||
}
|
||||
)
|
||||
if len(entries) >= max_entries:
|
||||
truncated = True
|
||||
break
|
||||
entries.sort(key=lambda x: (not x["is_dir"], str(x["name"]).lower()))
|
||||
return {
|
||||
"ne_id": str(device.get("id") or ""),
|
||||
"ne_name": str(device.get("name") or ""),
|
||||
"path": remote,
|
||||
"items": entries,
|
||||
"truncated": truncated,
|
||||
"max_entries": max_entries,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -771,17 +771,85 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
_mkdir_p(sftp, "/a/b/c") # idempotent
|
||||
self.assertEqual(sftp.mkdir_calls, ["/a", "/a/b", "/a/b/c"])
|
||||
|
||||
def test_sftp_rmtree(self) -> None:
|
||||
from netx_api.webcrt_sftp import _rmtree
|
||||
|
||||
class _Attr:
|
||||
def __init__(self, name: str, is_dir: bool = False) -> None:
|
||||
self.filename = name
|
||||
self.st_mode = 0o040755 if is_dir else 0o100644
|
||||
|
||||
class _FakeSftp:
|
||||
def __init__(self) -> None:
|
||||
self.tree = {
|
||||
"/p": ["a", "d"],
|
||||
"/p/d": ["f"],
|
||||
}
|
||||
self.removed: list[str] = []
|
||||
self.rmdirs: list[str] = []
|
||||
|
||||
def listdir_attr(self, path: str):
|
||||
names = self.tree.get(path, [])
|
||||
out = []
|
||||
for n in names:
|
||||
child = f"{path.rstrip('/')}/{n}"
|
||||
out.append(_Attr(n, is_dir=child in self.tree))
|
||||
return out
|
||||
|
||||
def remove(self, path: str) -> None:
|
||||
self.removed.append(path)
|
||||
|
||||
def rmdir(self, path: str) -> None:
|
||||
self.rmdirs.append(path)
|
||||
self.tree.pop(path, None)
|
||||
|
||||
sftp = _FakeSftp()
|
||||
_rmtree(sftp, "/p")
|
||||
self.assertEqual(sorted(sftp.removed), ["/p/a", "/p/d/f"])
|
||||
self.assertEqual(sftp.rmdirs, ["/p/d", "/p"])
|
||||
|
||||
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
|
||||
from netx_api.webcrt_sftp import (
|
||||
_content_disposition,
|
||||
_list_max_entries,
|
||||
_list_timeout_sec,
|
||||
_sftp_chunk_bytes,
|
||||
_sftp_max_file_bytes,
|
||||
)
|
||||
|
||||
self.assertGreaterEqual(_sftp_max_file_bytes(), 8 * 1024 * 1024)
|
||||
self.assertGreaterEqual(_sftp_chunk_bytes(), 4 * 1024)
|
||||
self.assertGreaterEqual(_list_max_entries(), 100)
|
||||
self.assertGreaterEqual(_list_timeout_sec(), 1.0)
|
||||
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)
|
||||
self.assertEqual(int(cfg.settings.webcrt_sftp_list_max_entries), 5000)
|
||||
|
||||
def test_sftp_rename_rejects_bad_paths(self) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from netx_api.webcrt_sftp import sftp_rename
|
||||
|
||||
db = MagicMock()
|
||||
with self.assertRaises(HTTPException) as cm:
|
||||
sftp_rename(db, managed_ne_id="n1", ume_ne_id=None, old_path=".", new_path="a")
|
||||
self.assertEqual(cm.exception.status_code, 400)
|
||||
self.assertEqual(cm.exception.detail, "sftp_path_required")
|
||||
|
||||
def test_sftp_parse_chmod_mode(self) -> None:
|
||||
from netx_api.webcrt_sftp import _parse_chmod_mode
|
||||
|
||||
self.assertEqual(_parse_chmod_mode("755"), 0o755)
|
||||
self.assertEqual(_parse_chmod_mode("0644"), 0o644)
|
||||
self.assertEqual(_parse_chmod_mode("rwxr-xr-x"), 0o755)
|
||||
self.assertEqual(_parse_chmod_mode("-rw-r--r--"), 0o644)
|
||||
with self.assertRaises(HTTPException) as cm:
|
||||
_parse_chmod_mode("bad")
|
||||
self.assertEqual(cm.exception.detail, "sftp_chmod_invalid_mode")
|
||||
|
||||
@patch.object(svc, "_audit")
|
||||
def test_find_ssh_session_for_ne_prefers_attached(self, _mock_audit: MagicMock) -> None:
|
||||
|
|
|
|||
|
|
@ -1046,14 +1046,39 @@ const en = {
|
|||
upload: "Upload",
|
||||
uploaded: "Upload complete",
|
||||
uploading: "Upload {{done}}/{{total}} · {{name}} {{pct}}",
|
||||
downloading: "Download {{name}} {{pct}}",
|
||||
downloading: "Download {{done}}/{{total}} · {{name}} {{pct}}",
|
||||
downloaded: "Downloaded {{count}} file(s)",
|
||||
downloadPartial: "Downloaded {{ok}}, failed {{failed}}",
|
||||
uploadPartial: "Uploaded {{ok}}, failed {{failed}}",
|
||||
retrying: "Retrying {{name}} ({{attempt}}/{{max}})…",
|
||||
cancel: "Cancel",
|
||||
dropHint: "Drop to upload into current folder",
|
||||
dropEmpty: "No uploadable files found",
|
||||
mkdir: "New",
|
||||
mkdirPrompt: "New folder name",
|
||||
mkdirInvalid: "Invalid folder name (no / or ..)",
|
||||
mkdirOk: "Folder created",
|
||||
rename: "Rename",
|
||||
renamePrompt: "New name",
|
||||
renameInvalid: "Invalid name (no / or ..)",
|
||||
renamed: "Renamed",
|
||||
download: "Download",
|
||||
chmod: "chmod",
|
||||
chmodPrompt: "Mode (e.g. 755 or rwxr-xr-x)",
|
||||
chmodOk: "Permissions updated",
|
||||
chmodPartial: "chmod ok {{ok}}, failed {{failed}}",
|
||||
delete: "Delete",
|
||||
deleteConfirm: "Delete {{name}}?",
|
||||
deleteRecursiveConfirm: "{{name}} includes folders; non-empty folders will be removed recursively. Continue?",
|
||||
deletePartial: "Deleted {{ok}}, failed {{failed}}",
|
||||
deleted: "Deleted",
|
||||
selectedCount: "{{count}} selected",
|
||||
empty: "This folder is empty",
|
||||
listTruncated: "Folder is large; showing first {{count}} entries (limit {{max}})",
|
||||
dir: "dir",
|
||||
loading: "Loading…",
|
||||
enterHint: "Double-click to open folder",
|
||||
downloadHint: "Click to download",
|
||||
enterHint: "Click to select; Ctrl/Shift multi-select; double-click to open",
|
||||
downloadHint: "Click to select; Ctrl/Shift multi-select; double-click to download",
|
||||
colName: "Name",
|
||||
colSize: "Size",
|
||||
colMtime: "Modified",
|
||||
|
|
@ -1091,6 +1116,8 @@ const en = {
|
|||
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)",
|
||||
sftpChmodInvalid: "Invalid mode (use 755 or rwxr-xr-x)",
|
||||
sftpAborted: "Transfer cancelled",
|
||||
queueDropped: "Terminal output too fast; about {{count}} chunk(s) dropped — transcript may be incomplete",
|
||||
websocket: "WebSocket connection failed",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1043,14 +1043,39 @@ const zh = {
|
|||
upload: "上传",
|
||||
uploaded: "上传成功",
|
||||
uploading: "上传 {{done}}/{{total}} · {{name}} {{pct}}",
|
||||
downloading: "下载 {{name}} {{pct}}",
|
||||
downloading: "下载 {{done}}/{{total}} · {{name}} {{pct}}",
|
||||
downloaded: "已下载 {{count}} 个文件",
|
||||
downloadPartial: "下载完成 {{ok}} 个,失败 {{failed}} 个",
|
||||
uploadPartial: "上传完成 {{ok}} 个,失败 {{failed}} 个",
|
||||
retrying: "重试 {{name}}({{attempt}}/{{max}})…",
|
||||
cancel: "取消",
|
||||
dropHint: "松开以上传到当前目录",
|
||||
dropEmpty: "未识别到可上传的文件",
|
||||
mkdir: "新建",
|
||||
mkdirPrompt: "新建目录名称",
|
||||
mkdirInvalid: "目录名无效(不要包含 / 或 ..)",
|
||||
mkdirOk: "目录已创建",
|
||||
rename: "重命名",
|
||||
renamePrompt: "新名称",
|
||||
renameInvalid: "名称无效(不要包含 / 或 ..)",
|
||||
renamed: "已重命名",
|
||||
download: "下载",
|
||||
chmod: "权限",
|
||||
chmodPrompt: "权限模式(如 755 或 rwxr-xr-x)",
|
||||
chmodOk: "权限已更新",
|
||||
chmodPartial: "权限更新 {{ok}} 个,失败 {{failed}} 个",
|
||||
delete: "删除",
|
||||
deleteConfirm: "确定删除 {{name}}?",
|
||||
deleteRecursiveConfirm: "{{name}} 含目录,非空目录将递归删除,是否继续?",
|
||||
deletePartial: "删除完成 {{ok}} 个,失败 {{failed}} 个",
|
||||
deleted: "已删除",
|
||||
selectedCount: "已选 {{count}} 项",
|
||||
empty: "当前目录为空",
|
||||
listTruncated: "目录条目过多,仅显示前 {{count}} 项(上限 {{max}})",
|
||||
dir: "目录",
|
||||
loading: "正在加载…",
|
||||
enterHint: "双击进入目录",
|
||||
downloadHint: "单击下载文件",
|
||||
enterHint: "单击选中;Ctrl/Shift 多选;双击进入",
|
||||
downloadHint: "单击选中;Ctrl/Shift 多选;双击下载",
|
||||
colName: "名称",
|
||||
colSize: "大小",
|
||||
colMtime: "修改时间",
|
||||
|
|
@ -1088,6 +1113,8 @@ const zh = {
|
|||
sftpNeedPassword: "SFTP 需要已保存的 SSH 密码(会话弹窗密码不会用于文件传输)",
|
||||
sftpUnsupported: "当前 SSH 会话未提供 SFTP(设备可能未开启)",
|
||||
sftpTooLarge: "文件超过大小限制(默认 512MB)",
|
||||
sftpChmodInvalid: "权限模式无效(请用 755 或 rwxr-xr-x)",
|
||||
sftpAborted: "传输已取消",
|
||||
queueDropped: "终端输出过快,已丢弃约 {{count}} 段,内容可能不完整",
|
||||
websocket: "WebSocket 连接失败",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3107,6 +3107,7 @@ pre {
|
|||
|
||||
.webcrt-sftp__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 6px;
|
||||
|
|
@ -3159,6 +3160,10 @@ pre {
|
|||
}
|
||||
|
||||
.webcrt-sftp__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
|
|
@ -3166,6 +3171,30 @@ pre {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-sftp__status--warn {
|
||||
color: #fbbf24;
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
}
|
||||
|
||||
.webcrt-sftp__cancel {
|
||||
flex-shrink: 0;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 4px;
|
||||
background: #1e293b;
|
||||
color: #f87171;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webcrt-sftp__cancel:hover {
|
||||
background: #7f1d1d;
|
||||
border-color: #ef4444;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
|
@ -3200,10 +3229,28 @@ pre {
|
|||
|
||||
.webcrt-sftp__th-label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-right: 4px;
|
||||
padding: 0 4px 0 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
text-transform: inherit;
|
||||
letter-spacing: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webcrt-sftp__th-label:hover {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table thead th.is-sorted .webcrt-sftp__th-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.webcrt-sftp__col-resizer {
|
||||
|
|
@ -3240,11 +3287,26 @@ pre {
|
|||
background: #111827;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table tbody tr.is-selected {
|
||||
background: #1e3a5f;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table tbody tr.is-selected:hover {
|
||||
background: #234872;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table tbody tr.is-busy {
|
||||
opacity: 0.7;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.webcrt-sftp__empty-row td {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding: 18px 8px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table td {
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
|
|
@ -3264,7 +3326,8 @@ pre {
|
|||
color: #64748b;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table thead th.webcrt-sftp__col-size {
|
||||
.webcrt-sftp__table thead th.webcrt-sftp__col-size,
|
||||
.webcrt-sftp__table thead th.webcrt-sftp__col-size .webcrt-sftp__th-label {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ import {
|
|||
updateManagedNe,
|
||||
webcrtSftpDownload,
|
||||
webcrtSftpList,
|
||||
webcrtSftpChmod,
|
||||
webcrtSftpMkdir,
|
||||
webcrtSftpRemove,
|
||||
webcrtSftpRename,
|
||||
webcrtSftpUpload,
|
||||
webcrtWsUrl,
|
||||
type WebcrtSftpItem,
|
||||
|
|
@ -496,6 +500,31 @@ function formatSftpTransferPct(loaded: number, total: number): string {
|
|||
return `${pct}%`;
|
||||
}
|
||||
|
||||
type SftpSortKey = SftpColKey;
|
||||
|
||||
function sortSftpItems(items: WebcrtSftpItem[], key: SftpSortKey, dir: "asc" | "desc"): WebcrtSftpItem[] {
|
||||
const mul = dir === "asc" ? 1 : -1;
|
||||
return [...items].sort((a, b) => {
|
||||
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||||
let cmp = 0;
|
||||
if (key === "size" || key === "mtime") {
|
||||
cmp = Number(a[key] || 0) - Number(b[key] || 0);
|
||||
} else if (key === "name") {
|
||||
cmp = String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base" });
|
||||
} else {
|
||||
cmp = String(a[key] || "").localeCompare(String(b[key] || ""), undefined, { sensitivity: "base" });
|
||||
}
|
||||
if (cmp !== 0) return cmp * mul;
|
||||
return String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
function sftpTargetIds(tab: Pick<TermTab, "target">): { ne_id?: string; ume_ne_id?: string } {
|
||||
return tab.target.source === "ume"
|
||||
? { ume_ne_id: tab.target.ume_ne_id || tab.target.id }
|
||||
: { ne_id: tab.target.id };
|
||||
}
|
||||
|
||||
type SftpUploadItem = { file: File; relativePath: string };
|
||||
|
||||
type FsEntryLike = {
|
||||
|
|
@ -592,6 +621,8 @@ 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("sftp_chmod_invalid_mode")) return t("webcrt.err.sftpChmodInvalid");
|
||||
if (raw.includes("aborted")) return t("webcrt.err.sftpAborted");
|
||||
if (raw.includes("websocket_error")) return t("webcrt.err.websocket");
|
||||
return raw;
|
||||
}
|
||||
|
|
@ -640,12 +671,22 @@ export function WebcrtPage() {
|
|||
const [sftpPath, setSftpPath] = useState(".");
|
||||
const [sftpBusy, setSftpBusy] = useState(false);
|
||||
const [sftpItems, setSftpItems] = useState<WebcrtSftpItem[]>([]);
|
||||
const [sftpSelected, setSftpSelected] = useState<string[]>([]);
|
||||
const [sftpSortKey, setSftpSortKey] = useState<SftpSortKey>("name");
|
||||
const [sftpSortDir, setSftpSortDir] = useState<"asc" | "desc">("asc");
|
||||
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 [sftpTransferring, setSftpTransferring] = useState(false);
|
||||
const [sftpListTruncated, setSftpListTruncated] = useState(false);
|
||||
const [sftpListMaxEntries, setSftpListMaxEntries] = useState(0);
|
||||
const sftpPathRef = useRef(".");
|
||||
const sftpPathByTabRef = useRef<Record<string, string>>({});
|
||||
const sftpActiveTabRef = useRef("");
|
||||
const sftpSelectAnchorRef = useRef("");
|
||||
const sftpAbortRef = useRef<AbortController | null>(null);
|
||||
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);
|
||||
|
|
@ -1287,15 +1328,16 @@ export function WebcrtPage() {
|
|||
const path = String(pathOverride ?? sftpPathRef.current ?? ".").trim() || ".";
|
||||
setSftpBusy(true);
|
||||
try {
|
||||
const body =
|
||||
tab.target.source === "ume"
|
||||
? { ume_ne_id: tab.target.ume_ne_id || tab.target.id, path }
|
||||
: { ne_id: tab.target.id, path };
|
||||
const res = await webcrtSftpList(body);
|
||||
const res = await webcrtSftpList({ ...sftpTargetIds(tab), path });
|
||||
setSftpItems(res.items || []);
|
||||
setSftpListTruncated(Boolean(res.truncated));
|
||||
setSftpListMaxEntries(Number(res.max_entries || 0));
|
||||
const nextPath = String(res.path || path).trim() || path;
|
||||
sftpPathRef.current = nextPath;
|
||||
setSftpPath(nextPath);
|
||||
if (activeTabKey) sftpPathByTabRef.current[activeTabKey] = nextPath;
|
||||
const names = new Set((res.items || []).map((it) => it.name));
|
||||
setSftpSelected((cur) => cur.filter((n) => names.has(n)));
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
|
|
@ -1310,22 +1352,291 @@ export function WebcrtPage() {
|
|||
const path = String(nextPath || ".").trim() || ".";
|
||||
sftpPathRef.current = path;
|
||||
setSftpPath(path);
|
||||
if (activeTabKey) sftpPathByTabRef.current[activeTabKey] = path;
|
||||
setSftpSelected([]);
|
||||
sftpSelectAnchorRef.current = "";
|
||||
void refreshSftp(path);
|
||||
},
|
||||
[refreshSftp],
|
||||
[activeTabKey, refreshSftp],
|
||||
);
|
||||
|
||||
const beginSftpTransfer = useCallback(() => {
|
||||
sftpAbortRef.current?.abort();
|
||||
const ac = new AbortController();
|
||||
sftpAbortRef.current = ac;
|
||||
setSftpTransferring(true);
|
||||
setSftpBusy(true);
|
||||
return ac;
|
||||
}, []);
|
||||
|
||||
const endSftpTransfer = useCallback(() => {
|
||||
sftpAbortRef.current = null;
|
||||
setSftpTransferring(false);
|
||||
setSftpBusy(false);
|
||||
setSftpStatus("");
|
||||
}, []);
|
||||
|
||||
const cancelSftpTransfer = useCallback(() => {
|
||||
sftpAbortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
const selectSftpRow = useCallback(
|
||||
(name: string, e: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean }, orderedNames: string[]) => {
|
||||
const multi = Boolean(e.ctrlKey || e.metaKey);
|
||||
const range = Boolean(e.shiftKey);
|
||||
if (range && sftpSelectAnchorRef.current) {
|
||||
const a = orderedNames.indexOf(sftpSelectAnchorRef.current);
|
||||
const b = orderedNames.indexOf(name);
|
||||
if (a >= 0 && b >= 0) {
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||
setSftpSelected(orderedNames.slice(lo, hi + 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (multi) {
|
||||
setSftpSelected((prev) =>
|
||||
prev.includes(name) ? prev.filter((x) => x !== name) : [...prev, name],
|
||||
);
|
||||
sftpSelectAnchorRef.current = name;
|
||||
return;
|
||||
}
|
||||
setSftpSelected([name]);
|
||||
sftpSelectAnchorRef.current = name;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const downloadSftpItems = useCallback(
|
||||
async (items: WebcrtSftpItem[]) => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
const files = items.filter((it) => !it.is_dir);
|
||||
if (!tab || !files.length || sftpBusy) return;
|
||||
const ac = beginSftpTransfer();
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
try {
|
||||
for (const it of files) {
|
||||
if (ac.signal.aborted) throw new Error("aborted");
|
||||
done += 1;
|
||||
setSftpStatus(
|
||||
t("webcrt.sftp.downloading", {
|
||||
name: it.name,
|
||||
pct: "0%",
|
||||
done,
|
||||
total: files.length,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const remote = joinSftpPath(sftpPathRef.current, it.name);
|
||||
const blob = await webcrtSftpDownload(
|
||||
{ ...sftpTargetIds(tab), path: remote },
|
||||
{
|
||||
signal: ac.signal,
|
||||
retries: 2,
|
||||
onRetry: (attempt) => {
|
||||
setSftpStatus(
|
||||
t("webcrt.sftp.retrying", { name: it.name, attempt, max: 3 }),
|
||||
);
|
||||
},
|
||||
onProgress: (p) => {
|
||||
const pct = formatSftpTransferPct(p.loaded, p.total || it.size);
|
||||
setSftpStatus(
|
||||
t("webcrt.sftp.downloading", {
|
||||
name: it.name,
|
||||
pct: pct || "…",
|
||||
done,
|
||||
total: files.length,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = it.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
if (String(err).includes("aborted")) throw err;
|
||||
failed += 1;
|
||||
if (files.length === 1) throw err;
|
||||
}
|
||||
}
|
||||
if (failed > 0) showError(t("webcrt.sftp.downloadPartial", { ok: files.length - failed, failed }));
|
||||
else if (files.length > 1) showOk(t("webcrt.sftp.downloaded", { count: files.length }));
|
||||
} catch (err) {
|
||||
if (!String(err).includes("aborted")) showError(webcrtErrorMessage(err, t));
|
||||
else showError(t("webcrt.err.sftpAborted"));
|
||||
} finally {
|
||||
endSftpTransfer();
|
||||
}
|
||||
},
|
||||
[activeTabKey, beginSftpTransfer, endSftpTransfer, showError, showOk, sftpBusy, t],
|
||||
);
|
||||
|
||||
const downloadSftpItem = useCallback(
|
||||
async (it: WebcrtSftpItem) => {
|
||||
await downloadSftpItems([it]);
|
||||
},
|
||||
[downloadSftpItems],
|
||||
);
|
||||
|
||||
const mkdirSftp = useCallback(async () => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab || sftpBusy) return;
|
||||
const name = window.prompt(t("webcrt.sftp.mkdirPrompt"), "");
|
||||
if (name == null) return;
|
||||
const trimmed = String(name).trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
if (!trimmed || trimmed.includes("..") || trimmed.includes("/")) {
|
||||
showError(t("webcrt.sftp.mkdirInvalid"));
|
||||
return;
|
||||
}
|
||||
setSftpBusy(true);
|
||||
try {
|
||||
const remote = joinSftpPath(sftpPathRef.current, trimmed);
|
||||
await webcrtSftpMkdir({ ...sftpTargetIds(tab), path: remote });
|
||||
showOk(t("webcrt.sftp.mkdirOk"));
|
||||
await refreshSftp(sftpPathRef.current);
|
||||
setSftpSelected([trimmed]);
|
||||
sftpSelectAnchorRef.current = trimmed;
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
}
|
||||
}, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, t]);
|
||||
|
||||
const removeSftpSelected = useCallback(async () => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab || !sftpSelected.length || sftpBusy) return;
|
||||
const items = sftpItems.filter((x) => sftpSelected.includes(x.name));
|
||||
if (!items.length) return;
|
||||
const label =
|
||||
items.length === 1 ? items[0].name : t("webcrt.sftp.selectedCount", { count: items.length });
|
||||
if (!window.confirm(t("webcrt.sftp.deleteConfirm", { name: label }))) return;
|
||||
const hasDir = items.some((x) => x.is_dir);
|
||||
let recursive = false;
|
||||
if (hasDir) {
|
||||
recursive = window.confirm(t("webcrt.sftp.deleteRecursiveConfirm", { name: label }));
|
||||
}
|
||||
setSftpBusy(true);
|
||||
let failed = 0;
|
||||
try {
|
||||
for (const item of items) {
|
||||
const remote = joinSftpPath(sftpPathRef.current, item.name);
|
||||
try {
|
||||
try {
|
||||
await webcrtSftpRemove({
|
||||
...sftpTargetIds(tab),
|
||||
path: remote,
|
||||
recursive: false,
|
||||
});
|
||||
} catch (err) {
|
||||
const raw = String(err);
|
||||
if (item.is_dir && raw.includes("sftp_dir_not_empty")) {
|
||||
if (!recursive) throw err;
|
||||
await webcrtSftpRemove({ ...sftpTargetIds(tab), path: remote, recursive: true });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
setSftpSelected([]);
|
||||
await refreshSftp(sftpPathRef.current);
|
||||
if (failed > 0) showError(t("webcrt.sftp.deletePartial", { ok: items.length - failed, failed }));
|
||||
else showOk(t("webcrt.sftp.deleted"));
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
}
|
||||
}, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, sftpItems, sftpSelected, t]);
|
||||
|
||||
const renameSftpSelected = useCallback(async () => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab || sftpSelected.length !== 1 || sftpBusy) return;
|
||||
const name = sftpSelected[0];
|
||||
const next = window.prompt(t("webcrt.sftp.renamePrompt"), name);
|
||||
if (next == null) return;
|
||||
const trimmed = String(next).trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
if (!trimmed || trimmed.includes("..") || trimmed.includes("/")) {
|
||||
showError(t("webcrt.sftp.renameInvalid"));
|
||||
return;
|
||||
}
|
||||
if (trimmed === name) return;
|
||||
const oldPath = joinSftpPath(sftpPathRef.current, name);
|
||||
const newPath = joinSftpPath(sftpPathRef.current, trimmed);
|
||||
setSftpBusy(true);
|
||||
try {
|
||||
await webcrtSftpRename({ ...sftpTargetIds(tab), old_path: oldPath, new_path: newPath });
|
||||
showOk(t("webcrt.sftp.renamed"));
|
||||
setSftpSelected([trimmed]);
|
||||
sftpSelectAnchorRef.current = trimmed;
|
||||
await refreshSftp(sftpPathRef.current);
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
}
|
||||
}, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, sftpSelected, t]);
|
||||
|
||||
const chmodSftpSelected = useCallback(async () => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab || !sftpSelected.length || sftpBusy) return;
|
||||
const sample = sftpItems.find((x) => x.name === sftpSelected[0]);
|
||||
const preset = sample?.mode ? sample.mode.replace(/^./, "").replace(/[^rwx-]/g, "") : "755";
|
||||
const mode = window.prompt(t("webcrt.sftp.chmodPrompt"), preset.length === 9 ? preset : "755");
|
||||
if (mode == null) return;
|
||||
const trimmed = String(mode).trim();
|
||||
if (!trimmed) return;
|
||||
setSftpBusy(true);
|
||||
let failed = 0;
|
||||
try {
|
||||
for (const name of sftpSelected) {
|
||||
const remote = joinSftpPath(sftpPathRef.current, name);
|
||||
try {
|
||||
await webcrtSftpChmod({ ...sftpTargetIds(tab), path: remote, mode: trimmed });
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
await refreshSftp(sftpPathRef.current);
|
||||
if (failed > 0) showError(t("webcrt.sftp.chmodPartial", { ok: sftpSelected.length - failed, failed }));
|
||||
else showOk(t("webcrt.sftp.chmodOk"));
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
}
|
||||
}, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, sftpItems, sftpSelected, t]);
|
||||
|
||||
const toggleSftpSort = useCallback((key: SftpSortKey) => {
|
||||
setSftpSortKey((prev) => {
|
||||
if (prev === key) {
|
||||
setSftpSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
return prev;
|
||||
}
|
||||
setSftpSortDir(key === "mtime" || key === "size" ? "desc" : "asc");
|
||||
return key;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const uploadSftpItems = useCallback(
|
||||
async (items: SftpUploadItem[]) => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab || !items.length) return;
|
||||
setSftpBusy(true);
|
||||
if (!tab || !items.length || sftpBusy) return;
|
||||
const ac = beginSftpTransfer();
|
||||
setSftpDragOver(false);
|
||||
sftpDragDepthRef.current = 0;
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
try {
|
||||
for (const item of items) {
|
||||
if (ac.signal.aborted) throw new Error("aborted");
|
||||
done += 1;
|
||||
const shortName = String(item.relativePath || item.file.name).split("/").pop() || item.file.name;
|
||||
setSftpStatus(
|
||||
|
|
@ -1341,26 +1652,29 @@ export function WebcrtPage() {
|
|||
}
|
||||
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 || "…",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await webcrtSftpUpload(
|
||||
{ ...sftpTargetIds(tab), remote_path: remote, file: item.file },
|
||||
{
|
||||
signal: ac.signal,
|
||||
retries: 2,
|
||||
onRetry: (attempt) => {
|
||||
setSftpStatus(t("webcrt.sftp.retrying", { name: shortName, attempt, max: 3 }));
|
||||
},
|
||||
onProgress: (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) {
|
||||
if (String(err).includes("aborted")) throw err;
|
||||
failed += 1;
|
||||
if (items.length === 1) throw err;
|
||||
}
|
||||
|
|
@ -1372,13 +1686,13 @@ export function WebcrtPage() {
|
|||
showOk(t("webcrt.sftp.uploaded"));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
if (String(err).includes("aborted")) showError(t("webcrt.err.sftpAborted"));
|
||||
else showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
setSftpStatus("");
|
||||
endSftpTransfer();
|
||||
}
|
||||
},
|
||||
[activeTabKey, refreshSftp, showError, showOk, t],
|
||||
[activeTabKey, beginSftpTransfer, endSftpTransfer, refreshSftp, showError, showOk, sftpBusy, t],
|
||||
);
|
||||
|
||||
const onSftpDragEnter = useCallback((e: ReactDragEvent<HTMLElement>) => {
|
||||
|
|
@ -1541,11 +1855,29 @@ export function WebcrtPage() {
|
|||
const activeTab = tabs.find((x) => x.key === activeTabKey) || null;
|
||||
const sftpBlockedKey = activeTab ? sftpUnavailableReason(activeTab) : "webcrt.err.sftpSsh";
|
||||
const sftpAllowed = Boolean(activeTab && !sftpBlockedKey);
|
||||
const sftpSortedItems = sortSftpItems(sftpItems, sftpSortKey, sftpSortDir);
|
||||
const sftpOrderedNames = sftpSortedItems.map((it) => it.name);
|
||||
const sftpSelectedItems = sftpSortedItems.filter((it) => sftpSelected.includes(it.name));
|
||||
const sftpSelectedFiles = sftpSelectedItems.filter((it) => !it.is_dir);
|
||||
|
||||
useEffect(() => {
|
||||
if (sftpOpen && !sftpAllowed) setSftpOpen(false);
|
||||
}, [sftpOpen, sftpAllowed]);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = sftpActiveTabRef.current;
|
||||
if (prev === activeTabKey) return;
|
||||
if (prev) sftpPathByTabRef.current[prev] = sftpPathRef.current;
|
||||
sftpActiveTabRef.current = activeTabKey;
|
||||
if (!sftpOpen || !activeTabKey || !sftpAllowed) return;
|
||||
const saved = sftpPathByTabRef.current[activeTabKey] || ".";
|
||||
sftpPathRef.current = saved;
|
||||
setSftpPath(saved);
|
||||
setSftpSelected([]);
|
||||
sftpSelectAnchorRef.current = "";
|
||||
void refreshSftp(saved);
|
||||
}, [activeTabKey, refreshSftp, sftpAllowed, sftpOpen]);
|
||||
|
||||
const renameWebcrtSession = useCallback(
|
||||
async (target: CliTargetItem, nextName: string) => {
|
||||
const name = nextName.trim();
|
||||
|
|
@ -1826,8 +2158,17 @@ export function WebcrtPage() {
|
|||
showError(t(sftpBlockedKey || "webcrt.err.sftpSsh"));
|
||||
return;
|
||||
}
|
||||
setSftpOpen((v) => !v);
|
||||
if (!sftpOpen) void refreshSftp();
|
||||
setSftpOpen((v) => {
|
||||
const next = !v;
|
||||
if (next) {
|
||||
const saved =
|
||||
sftpPathByTabRef.current[activeTabKey] || sftpPathRef.current || ".";
|
||||
sftpPathRef.current = saved;
|
||||
setSftpPath(saved);
|
||||
void refreshSftp(saved);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("webcrt.sftp.title")}
|
||||
|
|
@ -2069,6 +2410,37 @@ export function WebcrtPage() {
|
|||
<button type="button" disabled={sftpBusy} onClick={() => void refreshSftp()}>
|
||||
{t("webcrt.sftp.refresh")}
|
||||
</button>
|
||||
<button type="button" disabled={sftpBusy} onClick={() => void mkdirSftp()}>
|
||||
{t("webcrt.sftp.mkdir")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sftpBusy || sftpSelected.length !== 1}
|
||||
onClick={() => void renameSftpSelected()}
|
||||
>
|
||||
{t("webcrt.sftp.rename")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sftpBusy || sftpSelectedFiles.length === 0}
|
||||
onClick={() => void downloadSftpItems(sftpSelectedFiles)}
|
||||
>
|
||||
{t("webcrt.sftp.download")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sftpBusy || sftpSelected.length === 0}
|
||||
onClick={() => void chmodSftpSelected()}
|
||||
>
|
||||
{t("webcrt.sftp.chmod")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sftpBusy || sftpSelected.length === 0}
|
||||
onClick={() => void removeSftpSelected()}
|
||||
>
|
||||
{t("webcrt.sftp.delete")}
|
||||
</button>
|
||||
<label className={`webcrt-sftp__upload${sftpBusy ? " is-disabled" : ""}`}>
|
||||
{t("webcrt.sftp.upload")}
|
||||
<input
|
||||
|
|
@ -2087,7 +2459,24 @@ export function WebcrtPage() {
|
|||
</div>
|
||||
{sftpBusy || sftpStatus ? (
|
||||
<div className="webcrt-sftp__status" role="status">
|
||||
{sftpStatus || t("webcrt.sftp.loading")}
|
||||
<span>{sftpStatus || t("webcrt.sftp.loading")}</span>
|
||||
{sftpTransferring ? (
|
||||
<button
|
||||
type="button"
|
||||
className="webcrt-sftp__cancel"
|
||||
onClick={cancelSftpTransfer}
|
||||
>
|
||||
{t("webcrt.sftp.cancel")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!sftpBusy && sftpListTruncated ? (
|
||||
<div className="webcrt-sftp__status webcrt-sftp__status--warn" role="status">
|
||||
{t("webcrt.sftp.listTruncated", {
|
||||
count: sftpItems.length,
|
||||
max: sftpListMaxEntries || sftpItems.length,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{sftpDragOver ? (
|
||||
|
|
@ -2122,10 +2511,17 @@ export function WebcrtPage() {
|
|||
).map(([key, labelKey]) => (
|
||||
<th
|
||||
key={key}
|
||||
className={`webcrt-sftp__col-${key}`}
|
||||
className={`webcrt-sftp__col-${key}${sftpSortKey === key ? " is-sorted" : ""}`}
|
||||
style={{ width: sftpColWidths[key] }}
|
||||
>
|
||||
<span className="webcrt-sftp__th-label">{t(labelKey)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="webcrt-sftp__th-label"
|
||||
onClick={() => toggleSftpSort(key)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
{sftpSortKey === key ? (sftpSortDir === "asc" ? " ▲" : " ▼") : ""}
|
||||
</button>
|
||||
<span
|
||||
className="webcrt-sftp__col-resizer"
|
||||
title={t("webcrt.sftp.colResizeHint")}
|
||||
|
|
@ -2136,53 +2532,29 @@ export function WebcrtPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sftpItems.map((it) => (
|
||||
{!sftpBusy && sftpSortedItems.length === 0 ? (
|
||||
<tr className="webcrt-sftp__empty-row">
|
||||
<td colSpan={6}>{t("webcrt.sftp.empty")}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{sftpSortedItems.map((it) => (
|
||||
<tr
|
||||
key={`${sftpPath}:${it.is_dir ? "d" : "f"}:${it.name}`}
|
||||
className={sftpBusy ? "is-busy" : undefined}
|
||||
className={`${sftpBusy ? "is-busy" : ""}${
|
||||
sftpSelected.includes(it.name) ? " is-selected" : ""
|
||||
}`}
|
||||
title={it.is_dir ? t("webcrt.sftp.enterHint") : t("webcrt.sftp.downloadHint")}
|
||||
onClick={() => {
|
||||
if (it.is_dir || sftpBusy) return;
|
||||
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"
|
||||
? {
|
||||
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
|
||||
path: remote,
|
||||
}
|
||||
: { ne_id: activeTab.target.id, path: remote };
|
||||
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;
|
||||
a.download = it.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
setSftpStatus("");
|
||||
}
|
||||
})();
|
||||
onClick={(e) => {
|
||||
if (sftpBusy) return;
|
||||
selectSftpRow(it.name, e, sftpOrderedNames);
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (!it.is_dir || sftpBusy) return;
|
||||
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
|
||||
if (sftpBusy) return;
|
||||
if (it.is_dir) {
|
||||
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
|
||||
return;
|
||||
}
|
||||
void downloadSftpItem(it);
|
||||
}}
|
||||
>
|
||||
<td className="webcrt-sftp__col-name" style={{ width: sftpColWidths.name }}>
|
||||
|
|
|
|||
|
|
@ -563,29 +563,84 @@ export type WebcrtSftpListResult = {
|
|||
ne_name: string;
|
||||
path: string;
|
||||
items: WebcrtSftpItem[];
|
||||
truncated?: boolean;
|
||||
max_entries?: number;
|
||||
};
|
||||
|
||||
export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>
|
||||
apiPost<WebcrtSftpListResult>("/v1/webcrt/sftp/list", body);
|
||||
|
||||
export const webcrtSftpMkdir = (body: { ne_id?: string; ume_ne_id?: string; path: string }) =>
|
||||
apiPost<{ ok: boolean; path: string; ne_id: string }>("/v1/webcrt/sftp/mkdir", body);
|
||||
|
||||
export const webcrtSftpRemove = (body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
path: string;
|
||||
recursive?: boolean;
|
||||
}) => apiPost<{ ok: boolean; path: string; ne_id: string }>("/v1/webcrt/sftp/remove", body);
|
||||
|
||||
export const webcrtSftpRename = (body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
old_path: string;
|
||||
new_path: string;
|
||||
}) => apiPost<{ ok: boolean; old_path: string; new_path: string; ne_id: string }>("/v1/webcrt/sftp/rename", body);
|
||||
|
||||
export const webcrtSftpChmod = (body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
path: string;
|
||||
mode: string;
|
||||
}) => apiPost<{ ok: boolean; path: string; mode: string; ne_id: string }>("/v1/webcrt/sftp/chmod", body);
|
||||
|
||||
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,
|
||||
export type WebcrtSftpTransferOpts = {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (p: WebcrtSftpTransferProgress) => void;
|
||||
/** Extra attempts after the first failure. Default 2 (3 tries total). */
|
||||
retries?: number;
|
||||
onRetry?: (attempt: number, err: unknown) => void;
|
||||
};
|
||||
|
||||
const isAbortError = (err: unknown): boolean => {
|
||||
const raw = String(err || "").toLowerCase();
|
||||
return raw.includes("aborted") || raw.includes("abort");
|
||||
};
|
||||
|
||||
async function withSftpRetries<T>(
|
||||
run: () => Promise<T>,
|
||||
opts?: Pick<WebcrtSftpTransferOpts, "retries" | "signal" | "onRetry">,
|
||||
): Promise<T> {
|
||||
const retries = Math.max(0, Number(opts?.retries ?? 2));
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
if (opts?.signal?.aborted) throw new Error("aborted");
|
||||
try {
|
||||
return await run();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (isAbortError(err) || attempt >= retries) break;
|
||||
opts?.onRetry?.(attempt + 1, err);
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr || "transfer_failed"));
|
||||
}
|
||||
|
||||
async function webcrtSftpDownloadOnce(
|
||||
body: { ne_id?: string; ume_ne_id?: string; path: string },
|
||||
opts?: Pick<WebcrtSftpTransferOpts, "signal" | "onProgress">,
|
||||
): Promise<Blob> {
|
||||
const path = "/v1/webcrt/sftp/download";
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
signal: opts?.signal,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -598,32 +653,57 @@ export async function webcrtSftpDownload(
|
|||
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 });
|
||||
opts?.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 });
|
||||
try {
|
||||
for (;;) {
|
||||
if (opts?.signal?.aborted) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new Error("aborted");
|
||||
}
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
loaded += value.byteLength;
|
||||
opts?.onProgress?.({ loaded, total });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (opts?.signal?.aborted || isAbortError(err)) throw new Error("aborted");
|
||||
throw err;
|
||||
}
|
||||
return new Blob(chunks as BlobPart[], { type: "application/octet-stream" });
|
||||
}
|
||||
|
||||
export function webcrtSftpUpload(
|
||||
export async function webcrtSftpDownload(
|
||||
body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
path: string;
|
||||
},
|
||||
optsOrProgress?: WebcrtSftpTransferOpts | ((p: WebcrtSftpTransferProgress) => void),
|
||||
): Promise<Blob> {
|
||||
const opts: WebcrtSftpTransferOpts =
|
||||
typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress || {};
|
||||
return withSftpRetries(
|
||||
() => webcrtSftpDownloadOnce(body, opts),
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
function webcrtSftpUploadOnce(
|
||||
body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
remote_path: string;
|
||||
file: File;
|
||||
},
|
||||
onProgress?: (p: WebcrtSftpTransferProgress) => void,
|
||||
opts?: Pick<WebcrtSftpTransferOpts, "signal" | "onProgress">,
|
||||
): Promise<{ ok: boolean; path: string; size: number }> {
|
||||
const path = "/v1/webcrt/sftp/upload";
|
||||
const fd = new FormData();
|
||||
|
|
@ -634,18 +714,33 @@ export function webcrtSftpUpload(
|
|||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const onAbort = () => {
|
||||
try {
|
||||
xhr.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
if (opts?.signal) {
|
||||
if (opts.signal.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
opts.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
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({
|
||||
if (!opts?.onProgress) return;
|
||||
opts.onProgress({
|
||||
loaded: Number(ev.loaded || 0),
|
||||
total: ev.lengthComputable ? Number(ev.total || 0) : body.file.size || 0,
|
||||
});
|
||||
};
|
||||
xhr.onload = () => {
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
if (xhr.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
reject(new Error("401 unauthorized"));
|
||||
|
|
@ -661,12 +756,32 @@ export function webcrtSftpUpload(
|
|||
reject(new Error("invalid_upload_response"));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("network_error"));
|
||||
xhr.onabort = () => reject(new Error("aborted"));
|
||||
xhr.onerror = () => {
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
reject(new Error("network_error"));
|
||||
};
|
||||
xhr.onabort = () => {
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
reject(new Error("aborted"));
|
||||
};
|
||||
xhr.send(fd);
|
||||
});
|
||||
}
|
||||
|
||||
export function webcrtSftpUpload(
|
||||
body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
remote_path: string;
|
||||
file: File;
|
||||
},
|
||||
optsOrProgress?: WebcrtSftpTransferOpts | ((p: WebcrtSftpTransferProgress) => void),
|
||||
): Promise<{ ok: boolean; path: string; size: number }> {
|
||||
const opts: WebcrtSftpTransferOpts =
|
||||
typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress || {};
|
||||
return withSftpRetries(() => webcrtSftpUploadOnce(body, opts), opts);
|
||||
}
|
||||
|
||||
export const webcrtWsUrl = (sessionId: string): string => {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue