mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 08:00:49 +08:00
Enrich WebCRT SFTP listing and add a resizable terminal split.
Show size, mtime, owner, group, and mode, and let users drag the SFTP panel width. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
499b4cc2a1
commit
12153b89fb
7 changed files with 397 additions and 138 deletions
|
|
@ -184,6 +184,23 @@ def _sftp_client(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _filemode(mode: int) -> str:
|
||||||
|
try:
|
||||||
|
return str(statmod.filemode(int(mode)))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_group(attr: Any) -> tuple[str, str]:
|
||||||
|
longname = str(getattr(attr, "longname", "") or "").strip()
|
||||||
|
parts = longname.split()
|
||||||
|
if len(parts) >= 4 and parts[0][:1] in ("-", "d", "l", "c", "b", "p", "s"):
|
||||||
|
return str(parts[2]), str(parts[3])
|
||||||
|
uid = getattr(attr, "st_uid", None)
|
||||||
|
gid = getattr(attr, "st_gid", None)
|
||||||
|
return ("" if uid is None else str(uid), "" if gid is None else str(gid))
|
||||||
|
|
||||||
|
|
||||||
def sftp_list(
|
def sftp_list(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -200,12 +217,18 @@ def sftp_list(
|
||||||
name = str(attr.filename or "")
|
name = str(attr.filename or "")
|
||||||
if not name or name in (".", ".."):
|
if not name or name in (".", ".."):
|
||||||
continue
|
continue
|
||||||
|
owner, group = _owner_group(attr)
|
||||||
entries.append(
|
entries.append(
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"size": int(getattr(attr, "st_size", 0) or 0),
|
"size": int(getattr(attr, "st_size", 0) or 0),
|
||||||
"mtime": int(getattr(attr, "st_mtime", 0) or 0),
|
"mtime": int(getattr(attr, "st_mtime", 0) or 0),
|
||||||
"is_dir": bool(statmod.S_ISDIR(mode)),
|
"is_dir": bool(statmod.S_ISDIR(mode)),
|
||||||
|
"mode": _filemode(mode),
|
||||||
|
"owner": owner,
|
||||||
|
"group": group,
|
||||||
|
"uid": int(getattr(attr, "st_uid", 0) or 0),
|
||||||
|
"gid": int(getattr(attr, "st_gid", 0) or 0),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
entries.sort(key=lambda x: (not x["is_dir"], str(x["name"]).lower()))
|
entries.sort(key=lambda x: (not x["is_dir"], str(x["name"]).lower()))
|
||||||
|
|
|
||||||
|
|
@ -734,6 +734,20 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
# Direct SSH is allowed (no raise).
|
# Direct SSH is allowed (no raise).
|
||||||
_require_ssh_direct({"protocol": "ssh", "hop_enabled": False}, {"protocol": "ssh"})
|
_require_ssh_direct({"protocol": "ssh", "hop_enabled": False}, {"protocol": "ssh"})
|
||||||
|
|
||||||
|
def test_sftp_list_metadata_helpers(self) -> None:
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from netx_api.webcrt_sftp import _filemode, _owner_group
|
||||||
|
|
||||||
|
self.assertEqual(_filemode(0o100644), "-rw-r--r--")
|
||||||
|
self.assertEqual(_filemode(0o040755), "drwxr-xr-x")
|
||||||
|
owner, group = _owner_group(
|
||||||
|
SimpleNamespace(longname="-rw------- 1 alice eng 12 Jan 1 00:00 a", st_uid=1000, st_gid=100)
|
||||||
|
)
|
||||||
|
self.assertEqual((owner, group), ("alice", "eng"))
|
||||||
|
owner2, group2 = _owner_group(SimpleNamespace(longname="", st_uid=0, st_gid=1))
|
||||||
|
self.assertEqual((owner2, group2), ("0", "1"))
|
||||||
|
|
||||||
@patch.object(svc, "_audit")
|
@patch.object(svc, "_audit")
|
||||||
def test_find_ssh_session_for_ne_prefers_attached(self, _mock_audit: MagicMock) -> None:
|
def test_find_ssh_session_for_ne_prefers_attached(self, _mock_audit: MagicMock) -> None:
|
||||||
conn = _FakeConn()
|
conn = _FakeConn()
|
||||||
|
|
|
||||||
|
|
@ -1049,6 +1049,13 @@ const en = {
|
||||||
loading: "Loading…",
|
loading: "Loading…",
|
||||||
enterHint: "Double-click to open folder",
|
enterHint: "Double-click to open folder",
|
||||||
downloadHint: "Click to download",
|
downloadHint: "Click to download",
|
||||||
|
colName: "Name",
|
||||||
|
colSize: "Size (KB)",
|
||||||
|
colMtime: "Last modified",
|
||||||
|
colOwner: "Owner",
|
||||||
|
colGroup: "Group",
|
||||||
|
colMode: "Access",
|
||||||
|
resizeHint: "Drag to resize SFTP / terminal",
|
||||||
},
|
},
|
||||||
term: {
|
term: {
|
||||||
findPh: "Find…",
|
findPh: "Find…",
|
||||||
|
|
|
||||||
|
|
@ -1046,6 +1046,13 @@ const zh = {
|
||||||
loading: "正在加载…",
|
loading: "正在加载…",
|
||||||
enterHint: "双击进入目录",
|
enterHint: "双击进入目录",
|
||||||
downloadHint: "单击下载文件",
|
downloadHint: "单击下载文件",
|
||||||
|
colName: "名称",
|
||||||
|
colSize: "大小 (KB)",
|
||||||
|
colMtime: "修改时间",
|
||||||
|
colOwner: "所有者",
|
||||||
|
colGroup: "组",
|
||||||
|
colMode: "权限",
|
||||||
|
resizeHint: "拖动调整 SFTP / 终端宽度",
|
||||||
},
|
},
|
||||||
term: {
|
term: {
|
||||||
findPh: "查找…",
|
findPh: "查找…",
|
||||||
|
|
|
||||||
|
|
@ -2728,6 +2728,16 @@ pre {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #0b1220;
|
background: #0b1220;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-main__terms {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.webcrt-main__pane {
|
.webcrt-main__pane {
|
||||||
|
|
@ -3033,17 +3043,40 @@ pre {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.webcrt-sftp {
|
.webcrt-sftp-split {
|
||||||
position: absolute;
|
flex: 0 0 5px;
|
||||||
right: 0;
|
width: 5px;
|
||||||
top: 0;
|
cursor: col-resize;
|
||||||
bottom: 0;
|
background: #1e293b;
|
||||||
width: min(320px, 40%);
|
|
||||||
background: #0f172a;
|
|
||||||
border-left: 1px solid #334155;
|
border-left: 1px solid #334155;
|
||||||
|
border-right: 1px solid #334155;
|
||||||
|
z-index: 4;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp-split:hover,
|
||||||
|
.webcrt-sftp-split.is-dragging {
|
||||||
|
background: #0ea5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp-split::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 420px;
|
||||||
|
max-width: 75%;
|
||||||
|
min-width: 280px;
|
||||||
|
background: #0f172a;
|
||||||
|
border-left: 0;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.webcrt-sftp.is-busy {
|
.webcrt-sftp.is-busy {
|
||||||
|
|
@ -3055,6 +3088,7 @@ pre {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 1px solid #334155;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.webcrt-sftp__bar input {
|
.webcrt-sftp__bar input {
|
||||||
|
|
@ -3088,30 +3122,95 @@ pre {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
border-bottom: 1px solid #1e293b;
|
border-bottom: 1px solid #1e293b;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.webcrt-sftp__list {
|
.webcrt-sftp__table-wrap {
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: auto;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.webcrt-sftp__list button {
|
.webcrt-sftp__table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__table tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__table tbody tr:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__table tbody tr.is-busy {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__table td {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__col-name {
|
||||||
|
width: 36%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__col-size {
|
||||||
|
width: 12%;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__col-mtime {
|
||||||
|
width: 20%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__col-owner,
|
||||||
|
.webcrt-sftp__col-group {
|
||||||
|
width: 10%;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__col-mode {
|
||||||
|
width: 12%;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__name-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
text-align: left;
|
min-width: 0;
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 1px solid #1e293b;
|
|
||||||
color: #e2e8f0;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.webcrt-sftp__icon {
|
.webcrt-sftp__icon {
|
||||||
|
|
@ -3127,27 +3226,11 @@ pre {
|
||||||
|
|
||||||
.webcrt-sftp__name {
|
.webcrt-sftp__name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.webcrt-sftp__size {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: #64748b;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webcrt-sftp__list button:hover:not(:disabled) {
|
|
||||||
background: #1e293b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webcrt-sftp__list button:disabled {
|
|
||||||
opacity: 0.7;
|
|
||||||
cursor: progress;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.webcrt-shell {
|
.webcrt-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal";
|
import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal";
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
webcrtSftpList,
|
webcrtSftpList,
|
||||||
webcrtSftpUpload,
|
webcrtSftpUpload,
|
||||||
webcrtWsUrl,
|
webcrtWsUrl,
|
||||||
|
type WebcrtSftpItem,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
import { pageCount } from "../utils/display";
|
import { pageCount } from "../utils/display";
|
||||||
import type { CliTargetItem, ManagedNeItem } from "../types";
|
import type { CliTargetItem, ManagedNeItem } from "../types";
|
||||||
|
|
@ -32,6 +33,9 @@ import {
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions";
|
const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions";
|
||||||
const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs";
|
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 ENCODING_OPTIONS = ["utf-8", "gbk", "gb2312", "gb18030"] as const;
|
const ENCODING_OPTIONS = ["utf-8", "gbk", "gb2312", "gb18030"] as const;
|
||||||
const FONT_SIZE_OPTIONS = [12, 13, 14, 16, 18, 20] 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;
|
const PASTE_DELAY_OPTIONS = [0, 20, 40, 60, 100, 150] as const;
|
||||||
|
|
@ -378,6 +382,41 @@ function joinSftpPath(base: string, name: string): string {
|
||||||
return `${b}/${n}`;
|
return `${b}/${n}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadSftpWidth(): number {
|
||||||
|
try {
|
||||||
|
const n = Number(localStorage.getItem(SFTP_WIDTH_KEY));
|
||||||
|
if (Number.isFinite(n) && n >= SFTP_WIDTH_MIN) return Math.min(Math.round(n), 1200);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return SFTP_WIDTH_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSftpWidth(width: number): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SFTP_WIDTH_KEY, String(Math.round(width)));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSftpSizeKb(bytes: number, isDir: boolean): string {
|
||||||
|
if (isDir) return "";
|
||||||
|
const kb = Number(bytes || 0) / 1024;
|
||||||
|
if (!Number.isFinite(kb) || kb <= 0) return "0";
|
||||||
|
if (kb < 10) return kb.toFixed(1);
|
||||||
|
return String(Math.round(kb));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSftpMtime(sec: number): string {
|
||||||
|
const n = Number(sec || 0);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return "";
|
||||||
|
const d = new Date(n * 1000);
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
const pad = (x: number) => String(x).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
function connectFailedNe(err: unknown): ManagedNeItem | null {
|
function connectFailedNe(err: unknown): ManagedNeItem | null {
|
||||||
if (!(err instanceof ApiRequestError)) return null;
|
if (!(err instanceof ApiRequestError)) return null;
|
||||||
const detail = err.detail;
|
const detail = err.detail;
|
||||||
|
|
@ -453,8 +492,12 @@ export function WebcrtPage() {
|
||||||
const [sftpOpen, setSftpOpen] = useState(false);
|
const [sftpOpen, setSftpOpen] = useState(false);
|
||||||
const [sftpPath, setSftpPath] = useState(".");
|
const [sftpPath, setSftpPath] = useState(".");
|
||||||
const [sftpBusy, setSftpBusy] = useState(false);
|
const [sftpBusy, setSftpBusy] = useState(false);
|
||||||
const [sftpItems, setSftpItems] = useState<Array<{ name: string; size: number; mtime: number; is_dir: boolean }>>([]);
|
const [sftpItems, setSftpItems] = useState<WebcrtSftpItem[]>([]);
|
||||||
|
const [sftpWidth, setSftpWidth] = useState(() => loadSftpWidth());
|
||||||
|
const [sftpResizing, setSftpResizing] = useState(false);
|
||||||
const sftpPathRef = useRef(".");
|
const sftpPathRef = useRef(".");
|
||||||
|
const sftpBodyRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const sftpDragRef = useRef<{ startX: number; startW: number } | null>(null);
|
||||||
sftpPathRef.current = sftpPath;
|
sftpPathRef.current = sftpPath;
|
||||||
const [sessionOpts, setSessionOpts] = useState<SessionOptions>(() => loadSessionOptions());
|
const [sessionOpts, setSessionOpts] = useState<SessionOptions>(() => loadSessionOptions());
|
||||||
const [optionsMenuOpen, setOptionsMenuOpen] = useState(false);
|
const [optionsMenuOpen, setOptionsMenuOpen] = useState(false);
|
||||||
|
|
@ -1120,6 +1163,36 @@ export function WebcrtPage() {
|
||||||
[refreshSftp],
|
[refreshSftp],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onSftpSplitDown = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sftpDragRef.current = { startX: e.clientX, startW: sftpWidth };
|
||||||
|
setSftpResizing(true);
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
const drag = sftpDragRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
const bodyW = sftpBodyRef.current?.clientWidth || window.innerWidth;
|
||||||
|
const maxW = Math.max(SFTP_WIDTH_MIN, Math.floor(bodyW * 0.75));
|
||||||
|
const next = Math.max(SFTP_WIDTH_MIN, Math.min(maxW, drag.startW + (drag.startX - ev.clientX)));
|
||||||
|
setSftpWidth(next);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
sftpDragRef.current = null;
|
||||||
|
setSftpResizing(false);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
setSftpWidth((w) => {
|
||||||
|
saveSftpWidth(w);
|
||||||
|
return w;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.body.style.cursor = "col-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
}, [sftpWidth]);
|
||||||
|
|
||||||
// Auto-connect from /webcrt?ne_id=...
|
// Auto-connect from /webcrt?ne_id=...
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!presetNeId) return;
|
if (!presetNeId) return;
|
||||||
|
|
@ -1470,7 +1543,11 @@ export function WebcrtPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="webcrt-main__body">
|
<div
|
||||||
|
ref={sftpBodyRef}
|
||||||
|
className={`webcrt-main__body${sftpOpen && activeTab && sftpAllowed ? " has-sftp" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="webcrt-main__terms">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = activeTabKey === tab.key;
|
const isActive = activeTabKey === tab.key;
|
||||||
// Only the active tab mounts xterm + WS; background tabs detach and re-attach on focus.
|
// Only the active tab mounts xterm + WS; background tabs detach and re-attach on focus.
|
||||||
|
|
@ -1655,85 +1732,58 @@ export function WebcrtPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
{sftpOpen && activeTab && sftpAllowed ? (
|
{sftpOpen && activeTab && sftpAllowed ? (
|
||||||
<div className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}`}>
|
<>
|
||||||
<div className="webcrt-sftp__bar">
|
<div
|
||||||
<button
|
className={`webcrt-sftp-split${sftpResizing ? " is-dragging" : ""}`}
|
||||||
type="button"
|
role="separator"
|
||||||
title={t("webcrt.sftp.up")}
|
aria-orientation="vertical"
|
||||||
disabled={sftpBusy}
|
aria-label={t("webcrt.sftp.resizeHint")}
|
||||||
onClick={() => navigateSftp(sftpParentPath(sftpPathRef.current))}
|
title={t("webcrt.sftp.resizeHint")}
|
||||||
>
|
onMouseDown={onSftpSplitDown}
|
||||||
{t("webcrt.sftp.up")}
|
/>
|
||||||
</button>
|
<aside
|
||||||
<input
|
className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}`}
|
||||||
value={sftpPath}
|
style={{ width: sftpWidth }}
|
||||||
disabled={sftpBusy}
|
>
|
||||||
onChange={(e) => {
|
<div className="webcrt-sftp__bar">
|
||||||
const v = e.target.value;
|
<button
|
||||||
sftpPathRef.current = v;
|
type="button"
|
||||||
setSftpPath(v);
|
title={t("webcrt.sftp.up")}
|
||||||
}}
|
disabled={sftpBusy}
|
||||||
onKeyDown={(e) => {
|
onClick={() => navigateSftp(sftpParentPath(sftpPathRef.current))}
|
||||||
if (e.key === "Enter") {
|
>
|
||||||
e.preventDefault();
|
{t("webcrt.sftp.up")}
|
||||||
void refreshSftp(sftpPathRef.current);
|
</button>
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button type="button" disabled={sftpBusy} onClick={() => void refreshSftp()}>
|
|
||||||
{t("webcrt.sftp.refresh")}
|
|
||||||
</button>
|
|
||||||
<label className={`webcrt-sftp__upload${sftpBusy ? " is-disabled" : ""}`}>
|
|
||||||
{t("webcrt.sftp.upload")}
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
value={sftpPath}
|
||||||
hidden
|
|
||||||
disabled={sftpBusy}
|
disabled={sftpBusy}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const v = e.target.value;
|
||||||
if (!file || !activeTab || sftpBusy) return;
|
sftpPathRef.current = v;
|
||||||
const remote = joinSftpPath(sftpPathRef.current, file.name);
|
setSftpPath(v);
|
||||||
void (async () => {
|
}}
|
||||||
setSftpBusy(true);
|
onKeyDown={(e) => {
|
||||||
try {
|
if (e.key === "Enter") {
|
||||||
const body =
|
e.preventDefault();
|
||||||
activeTab.target.source === "ume"
|
void refreshSftp(sftpPathRef.current);
|
||||||
? {
|
}
|
||||||
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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
e.target.value = "";
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
<button type="button" disabled={sftpBusy} onClick={() => void refreshSftp()}>
|
||||||
</div>
|
{t("webcrt.sftp.refresh")}
|
||||||
{sftpBusy ? (
|
</button>
|
||||||
<div className="webcrt-sftp__status" role="status">
|
<label className={`webcrt-sftp__upload${sftpBusy ? " is-disabled" : ""}`}>
|
||||||
{t("webcrt.sftp.loading")}
|
{t("webcrt.sftp.upload")}
|
||||||
</div>
|
<input
|
||||||
) : null}
|
type="file"
|
||||||
<ul className="webcrt-sftp__list">
|
hidden
|
||||||
{sftpItems.map((it) => (
|
|
||||||
<li key={`${sftpPath}:${it.is_dir ? "d" : "f"}:${it.name}`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={sftpBusy}
|
disabled={sftpBusy}
|
||||||
title={it.is_dir ? t("webcrt.sftp.enterHint") : t("webcrt.sftp.downloadHint")}
|
onChange={(e) => {
|
||||||
onClick={() => {
|
const file = e.target.files?.[0];
|
||||||
if (it.is_dir || sftpBusy) return;
|
if (!file || !activeTab || sftpBusy) return;
|
||||||
const remote = joinSftpPath(sftpPathRef.current, it.name);
|
const remote = joinSftpPath(sftpPathRef.current, file.name);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setSftpBusy(true);
|
setSftpBusy(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -1741,38 +1791,101 @@ export function WebcrtPage() {
|
||||||
activeTab.target.source === "ume"
|
activeTab.target.source === "ume"
|
||||||
? {
|
? {
|
||||||
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
|
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
|
||||||
path: remote,
|
remote_path: remote,
|
||||||
|
file,
|
||||||
}
|
}
|
||||||
: { ne_id: activeTab.target.id, path: remote };
|
: { ne_id: activeTab.target.id, remote_path: remote, file };
|
||||||
const blob = await webcrtSftpDownload(body);
|
await webcrtSftpUpload(body);
|
||||||
const url = URL.createObjectURL(blob);
|
showOk(t("webcrt.sftp.uploaded"));
|
||||||
const a = document.createElement("a");
|
await refreshSftp(sftpPathRef.current);
|
||||||
a.href = url;
|
|
||||||
a.download = it.name;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(webcrtErrorMessage(err, t));
|
showError(webcrtErrorMessage(err, t));
|
||||||
} finally {
|
} finally {
|
||||||
setSftpBusy(false);
|
setSftpBusy(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => {
|
/>
|
||||||
if (!it.is_dir || sftpBusy) return;
|
</label>
|
||||||
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
|
</div>
|
||||||
}}
|
{sftpBusy ? (
|
||||||
>
|
<div className="webcrt-sftp__status" role="status">
|
||||||
<span className={`webcrt-sftp__icon${it.is_dir ? " is-dir" : ""}`}>
|
{t("webcrt.sftp.loading")}
|
||||||
{it.is_dir ? <FolderIcon /> : <FileIcon />}
|
</div>
|
||||||
</span>
|
) : null}
|
||||||
<span className="webcrt-sftp__name">{it.name}</span>
|
<div className="webcrt-sftp__table-wrap">
|
||||||
{!it.is_dir ? <span className="webcrt-sftp__size">({it.size})</span> : null}
|
<table className="webcrt-sftp__table">
|
||||||
</button>
|
<thead>
|
||||||
</li>
|
<tr>
|
||||||
))}
|
<th className="webcrt-sftp__col-name">{t("webcrt.sftp.colName")}</th>
|
||||||
</ul>
|
<th className="webcrt-sftp__col-size">{t("webcrt.sftp.colSize")}</th>
|
||||||
</div>
|
<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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sftpItems.map((it) => (
|
||||||
|
<tr
|
||||||
|
key={`${sftpPath}:${it.is_dir ? "d" : "f"}:${it.name}`}
|
||||||
|
className={sftpBusy ? "is-busy" : undefined}
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
if (!it.is_dir || sftpBusy) return;
|
||||||
|
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="webcrt-sftp__col-name">
|
||||||
|
<span className="webcrt-sftp__name-cell">
|
||||||
|
<span className={`webcrt-sftp__icon${it.is_dir ? " is-dir" : ""}`}>
|
||||||
|
{it.is_dir ? <FolderIcon /> : <FileIcon />}
|
||||||
|
</span>
|
||||||
|
<span className="webcrt-sftp__name">{it.name}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="webcrt-sftp__col-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>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -546,11 +546,23 @@ export const closeWebcrtSession = (sessionId: string) =>
|
||||||
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
|
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type WebcrtSftpItem = {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mtime: number;
|
||||||
|
is_dir: boolean;
|
||||||
|
mode?: string;
|
||||||
|
owner?: string;
|
||||||
|
group?: string;
|
||||||
|
uid?: number;
|
||||||
|
gid?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type WebcrtSftpListResult = {
|
export type WebcrtSftpListResult = {
|
||||||
ne_id: string;
|
ne_id: string;
|
||||||
ne_name: string;
|
ne_name: string;
|
||||||
path: string;
|
path: string;
|
||||||
items: Array<{ name: string; size: number; mtime: number; is_dir: boolean }>;
|
items: WebcrtSftpItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>
|
export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue