mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Harden WebCRT interactive sessions for more CRT-like device login.
Improve async connect, binary stdout coalescing, paste throttling with progress, SSH keepalive, login prompt settle, and optional SFTP while keeping core terminal UX focused. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cb865254e4
commit
accd28c9a1
13 changed files with 1884 additions and 224 deletions
10
web/WEB.md
10
web/WEB.md
|
|
@ -139,12 +139,14 @@ src/
|
|||
|
||||
## WebCRT
|
||||
|
||||
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
|
||||
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`;可选 `encoding`/`post_login_commands`/`async_connect`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
|
||||
- SFTP(直连 SSH):`POST /v1/webcrt/sftp/list|download|upload`
|
||||
- 目标列表复用 `/v1/cli/targets`(托管 + UME,搜索分页;`source=all|managed|ume`)
|
||||
- 凭据:托管走网元自身账号;UME 走 CLI 连接模板(`resolve_cli_target`)
|
||||
- 前端:CRT 风格左右分栏(会话管理 + 多标签终端)
|
||||
- 审计:`NETX_WEBCRT_DATA_DIR`(默认 `data/webcrt/audit.jsonl`)
|
||||
- 限流:`NETX_WEBCRT_MAX_SESSIONS`、`NETX_WEBCRT_IDLE_TIMEOUT_SEC`
|
||||
- 前端:CRT 风格左右分栏(收藏/最近 + 多标签终端);粘贴节流、选区复制、Break、Ctrl+F、Button Bar、广播、编码/字号
|
||||
- WS:stdout 二进制帧合批;stdin 短合批;建连可异步(POST 立即返回,进度经 WS `connecting`→`connected`)
|
||||
- 审计 / 会话日志:`NETX_WEBCRT_DATA_DIR`(`audit.jsonl` + `sessions/*.log`)
|
||||
- 限流 / 保活:`NETX_WEBCRT_MAX_SESSIONS`、`NETX_WEBCRT_IDLE_TIMEOUT_SEC`、`NETX_WEBCRT_KEEPALIVE_SEC`、`NETX_WEBCRT_ANTI_IDLE_SEC`
|
||||
|
||||
## Toast
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
|
@ -6,9 +6,14 @@ import "@xterm/xterm/css/xterm.css";
|
|||
export type WebTerminalHandle = {
|
||||
clear: () => void;
|
||||
copyAll: () => Promise<string>;
|
||||
copySelection: () => Promise<string>;
|
||||
getText: () => string;
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
sendBreak: () => void;
|
||||
sendText: (data: string, opts?: { throttle?: boolean }) => void;
|
||||
findNext: (term: string) => void;
|
||||
findPrevious: (term: string) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
|
|
@ -16,6 +21,9 @@ type Props = {
|
|||
title?: string;
|
||||
recording?: boolean;
|
||||
autoFocus?: boolean;
|
||||
encoding?: string;
|
||||
pasteDelayMs?: number;
|
||||
copyOnSelect?: boolean;
|
||||
onStatus?: (state: string, message?: string) => void;
|
||||
onReady?: () => void;
|
||||
onStdout?: (data: string) => void;
|
||||
|
|
@ -35,11 +43,10 @@ function serializeTerminal(term: Terminal): string {
|
|||
function isSidebarSearchTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") {
|
||||
// xterm's hidden textarea must still receive keys normally.
|
||||
if (target.classList.contains("xterm-helper-textarea")) return false;
|
||||
return true;
|
||||
}
|
||||
return Boolean(target.closest(".webcrt-sidebar__search"));
|
||||
return Boolean(target.closest(".webcrt-sidebar__search") || target.closest(".webcrt-ctx") || target.closest(".webcrt-find"));
|
||||
}
|
||||
|
||||
function isXtermTextarea(target: EventTarget | null): boolean {
|
||||
|
|
@ -49,7 +56,7 @@ function isXtermTextarea(target: EventTarget | null): boolean {
|
|||
/** Map a browser key event to the bytes xterm/onData would normally emit. */
|
||||
function keyEventToStdin(e: KeyboardEvent): string | null {
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return null;
|
||||
if (e.key === "Backspace") return "\x08"; // BS — common SecureCRT/VT default
|
||||
if (e.key === "Backspace") return "\x08";
|
||||
if (e.key === "Enter") return "\r";
|
||||
if (e.key === "Tab") return "\t";
|
||||
if (e.key === "Escape") return "\x1b";
|
||||
|
|
@ -64,19 +71,65 @@ function keyEventToStdin(e: KeyboardEvent): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function decodeBytes(buf: ArrayBuffer, _encoding: string): string {
|
||||
// Server normalizes device encodings (e.g. GBK) to UTF-8 on the wire.
|
||||
try {
|
||||
return new TextDecoder("utf-8").decode(buf);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const PREF_KEY = "netx.webcrt.termPrefs";
|
||||
|
||||
function loadPrefs(): { copyOnSelect: boolean; pasteDelayMs: number } {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREF_KEY);
|
||||
if (!raw) return { copyOnSelect: true, pasteDelayMs: 40 };
|
||||
const j = JSON.parse(raw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>;
|
||||
return {
|
||||
copyOnSelect: j.copyOnSelect !== false,
|
||||
pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs) || 40)),
|
||||
};
|
||||
} catch {
|
||||
return { copyOnSelect: true, pasteDelayMs: 40 };
|
||||
}
|
||||
}
|
||||
|
||||
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
|
||||
{ wsUrl, title, recording, autoFocus = true, onStatus, onReady, onStdout },
|
||||
{
|
||||
wsUrl,
|
||||
title,
|
||||
recording,
|
||||
autoFocus = true,
|
||||
encoding = "utf-8",
|
||||
pasteDelayMs,
|
||||
copyOnSelect,
|
||||
onStatus,
|
||||
onReady,
|
||||
onStdout,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const findIndexRef = useRef(0);
|
||||
const onStatusRef = useRef(onStatus);
|
||||
const onReadyRef = useRef(onReady);
|
||||
const onStdoutRef = useRef(onStdout);
|
||||
const recordingRef = useRef(!!recording);
|
||||
const autoFocusRef = useRef(autoFocus);
|
||||
const encodingRef = useRef(encoding);
|
||||
const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs);
|
||||
const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect);
|
||||
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
const [findQuery, setFindQuery] = useState("");
|
||||
const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null);
|
||||
const findInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusRef.current = onStatus;
|
||||
|
|
@ -92,6 +145,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
autoFocusRef.current = autoFocus;
|
||||
}, [autoFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
encodingRef.current = encoding;
|
||||
}, [encoding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pasteDelayMs != null) pasteDelayRef.current = pasteDelayMs;
|
||||
}, [pasteDelayMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect;
|
||||
}, [copyOnSelect]);
|
||||
|
||||
const focusTerminal = () => {
|
||||
try {
|
||||
termRef.current?.focus();
|
||||
|
|
@ -100,6 +165,46 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
};
|
||||
|
||||
const sendJson = (payload: Record<string, unknown>) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
};
|
||||
|
||||
const sendStdinImmediate = (data: string) => {
|
||||
if (!data) return;
|
||||
sendJson({ type: "stdin", data });
|
||||
};
|
||||
|
||||
/** SecureCRT-like paste: send line-by-line with delay; show progress while sending. */
|
||||
const sendStdinThrottled = (data: string) => {
|
||||
if (!data) return;
|
||||
const delay = pasteDelayRef.current;
|
||||
if (delay <= 0 || data.length < 8) {
|
||||
sendStdinImmediate(data);
|
||||
return;
|
||||
}
|
||||
pasteQueueRef.current = pasteQueueRef.current.then(async () => {
|
||||
const lines = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const total = Math.max(1, lines.length);
|
||||
setPasteStatus({ done: 0, total });
|
||||
try {
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const chunk = i < lines.length - 1 ? `${line}\r` : line;
|
||||
if (chunk) sendStdinImmediate(chunk);
|
||||
setPasteStatus({ done: i + 1, total });
|
||||
if (i < lines.length - 1) {
|
||||
await new Promise((r) => window.setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setPasteStatus(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clear: () => {
|
||||
termRef.current?.clear();
|
||||
|
|
@ -112,6 +217,13 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
return text;
|
||||
},
|
||||
copySelection: async () => {
|
||||
const text = termRef.current?.getSelection() || "";
|
||||
if (text && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
return text;
|
||||
},
|
||||
getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
|
||||
fit: () => {
|
||||
try {
|
||||
|
|
@ -121,6 +233,39 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
},
|
||||
focus: focusTerminal,
|
||||
sendBreak: () => sendJson({ type: "break" }),
|
||||
sendText: (data: string, opts?: { throttle?: boolean }) => {
|
||||
if (opts?.throttle) sendStdinThrottled(data);
|
||||
else sendStdinImmediate(data);
|
||||
},
|
||||
findNext: (q: string) => {
|
||||
const term = termRef.current;
|
||||
if (!term || !q) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = q.toLowerCase();
|
||||
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||
if (idx < 0) idx = text.indexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
// Approximate scroll: each buffer line ~1 row.
|
||||
const line = text.slice(0, idx).split("\n").length - 1;
|
||||
term.scrollToLine(Math.max(0, line - 2));
|
||||
}
|
||||
},
|
||||
findPrevious: (q: string) => {
|
||||
const term = termRef.current;
|
||||
if (!term || !q) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = q.toLowerCase();
|
||||
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||
let idx = before.lastIndexOf(needle);
|
||||
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
const line = text.slice(0, idx).split("\n").length - 1;
|
||||
term.scrollToLine(Math.max(0, line - 2));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -131,10 +276,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
scrollback: 10000,
|
||||
theme: {
|
||||
background: "#0b1220",
|
||||
foreground: "#e2e8f0",
|
||||
cursor: "#e2e8f0",
|
||||
selectionBackground: "#334155",
|
||||
},
|
||||
convertEol: true,
|
||||
});
|
||||
|
|
@ -155,7 +302,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
if (!autoFocusRef.current) return;
|
||||
focusTerminal();
|
||||
};
|
||||
// Focus immediately so the first keystroke is not lost to the sidebar/body.
|
||||
maybeFocus();
|
||||
requestAnimationFrame(() => {
|
||||
doFit();
|
||||
|
|
@ -167,22 +313,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
});
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
onStatusRef.current?.("connecting");
|
||||
|
||||
const sendJson = (payload: Record<string, unknown>) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
};
|
||||
|
||||
const sendStdin = (data: string) => {
|
||||
if (!data) return;
|
||||
sendJson({ type: "stdin", data });
|
||||
};
|
||||
|
||||
// Display only what the device echoes (chars, Tab completion, BS erase, etc.).
|
||||
// No local echo / local erase — those desync on Tab complete and prompt redraw.
|
||||
const writeStdout = (raw: string) => {
|
||||
if (raw) term.write(raw);
|
||||
if (recordingRef.current) onStdoutRef.current?.(raw);
|
||||
|
|
@ -201,6 +335,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
writeStdout(decodeBytes(ev.data, encodingRef.current));
|
||||
maybeFocus();
|
||||
return;
|
||||
}
|
||||
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
||||
void ev.data.arrayBuffer().then((buf) => {
|
||||
writeStdout(decodeBytes(buf, encodingRef.current));
|
||||
maybeFocus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const msg = JSON.parse(String(ev.data || "{}")) as {
|
||||
type?: string;
|
||||
|
|
@ -215,7 +361,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
if (msg.type === "status") {
|
||||
onStatusRef.current?.(String(msg.state || ""), msg.message);
|
||||
if (msg.state === "connected") {
|
||||
if (msg.state === "connected" || msg.state === "connecting") {
|
||||
maybeFocus();
|
||||
return;
|
||||
}
|
||||
|
|
@ -243,27 +389,41 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
};
|
||||
|
||||
// When focused, xterm onData sends keystrokes.
|
||||
const dataDisposable = term.onData((data) => {
|
||||
// Normalize Backspace: xterm emits DEL(0x7f); devices expect BS(0x08) like default SecureCRT.
|
||||
const normalized = data.replace(/\x7f/g, "\x08");
|
||||
sendStdin(normalized);
|
||||
// Large pastes from xterm arrive as one onData blob.
|
||||
if (normalized.length > 32 || normalized.includes("\r") || normalized.includes("\n")) {
|
||||
sendStdinThrottled(normalized);
|
||||
} else {
|
||||
sendStdinImmediate(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
const selDisposable = term.onSelectionChange(() => {
|
||||
if (!copyOnSelectRef.current) return;
|
||||
const sel = term.getSelection();
|
||||
if (sel && navigator.clipboard?.writeText) {
|
||||
void navigator.clipboard.writeText(sel).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
// When NOT focused (sidebar still focused after click), capture keys and forward
|
||||
// so the first character / Backspace are not lost to the browser.
|
||||
const onKeyDownCapture = (e: KeyboardEvent) => {
|
||||
if (!autoFocusRef.current) return;
|
||||
if (host.closest("[hidden]")) return;
|
||||
if (isSidebarSearchTarget(e.target)) return;
|
||||
|
||||
// Always block browser "Backspace = history back" while this session pane is active.
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F")) {
|
||||
e.preventDefault();
|
||||
setFindOpen(true);
|
||||
window.setTimeout(() => findInputRef.current?.focus(), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (isXtermTextarea(e.target)) {
|
||||
// Let xterm onData handle it; we only prevented browser back above.
|
||||
maybeFocus();
|
||||
return;
|
||||
}
|
||||
|
|
@ -274,10 +434,31 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
maybeFocus();
|
||||
sendStdin(data);
|
||||
sendStdinImmediate(data);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDownCapture, true);
|
||||
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
host.addEventListener("contextmenu", onContextMenu);
|
||||
|
||||
const onPasteCapture = (e: ClipboardEvent) => {
|
||||
if (!autoFocusRef.current) return;
|
||||
if (host.closest("[hidden]")) return;
|
||||
if (isSidebarSearchTarget(e.target)) return;
|
||||
const text = e.clipboardData?.getData("text") || "";
|
||||
if (!text) return;
|
||||
// Intercept paste so we always apply delay (even if focus is odd).
|
||||
if (document.activeElement === term.textarea || host.contains(document.activeElement) || autoFocusRef.current) {
|
||||
e.preventDefault();
|
||||
sendStdinThrottled(text);
|
||||
maybeFocus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPasteCapture, true);
|
||||
|
||||
const pingTimer = window.setInterval(() => {
|
||||
sendJson({ type: "ping" });
|
||||
}, 25000);
|
||||
|
|
@ -297,8 +478,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
window.clearInterval(pingTimer);
|
||||
window.removeEventListener("resize", onWinResize);
|
||||
window.removeEventListener("keydown", onKeyDownCapture, true);
|
||||
window.removeEventListener("paste", onPasteCapture, true);
|
||||
host.removeEventListener("contextmenu", onContextMenu);
|
||||
ro?.disconnect();
|
||||
dataDisposable.dispose();
|
||||
selDisposable.dispose();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
|
|
@ -311,13 +495,147 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
};
|
||||
}, [wsUrl, title]);
|
||||
|
||||
const pasteFromClipboard = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
sendStdinThrottled(text);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setCtxMenu(null);
|
||||
focusTerminal();
|
||||
};
|
||||
|
||||
const copySelection = async () => {
|
||||
const text = termRef.current?.getSelection() || "";
|
||||
if (text && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
setCtxMenu(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="webcrt-term"
|
||||
ref={hostRef}
|
||||
onMouseDown={() => {
|
||||
focusTerminal();
|
||||
}}
|
||||
/>
|
||||
<div className="webcrt-term-wrap">
|
||||
{pasteStatus ? (
|
||||
<div className="webcrt-paste-status" role="status" aria-live="polite">
|
||||
粘贴中 {pasteStatus.done}/{pasteStatus.total} 行…
|
||||
</div>
|
||||
) : null}
|
||||
{findOpen ? (
|
||||
<div className="webcrt-find">
|
||||
<input
|
||||
ref={findInputRef}
|
||||
type="search"
|
||||
value={findQuery}
|
||||
placeholder="Find…"
|
||||
onChange={(e) => setFindQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const term = termRef.current;
|
||||
if (!term || !findQuery) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = findQuery.toLowerCase();
|
||||
if (e.shiftKey) {
|
||||
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||
let idx = before.lastIndexOf(needle);
|
||||
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
}
|
||||
} else {
|
||||
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||
if (idx < 0) idx = text.indexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
}
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setFindOpen(false);
|
||||
focusTerminal();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const term = termRef.current;
|
||||
if (!term || !findQuery) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = findQuery.toLowerCase();
|
||||
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||
let idx = before.lastIndexOf(needle);
|
||||
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
}
|
||||
}}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const term = termRef.current;
|
||||
if (!term || !findQuery) return;
|
||||
const text = serializeTerminal(term).toLowerCase();
|
||||
const needle = findQuery.toLowerCase();
|
||||
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||
if (idx < 0) idx = text.indexOf(needle);
|
||||
if (idx >= 0) {
|
||||
findIndexRef.current = idx;
|
||||
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||
}
|
||||
}}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFindOpen(false);
|
||||
focusTerminal();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="webcrt-term"
|
||||
ref={hostRef}
|
||||
onMouseDown={() => {
|
||||
focusTerminal();
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
/>
|
||||
{ctxMenu ? (
|
||||
<div
|
||||
className="webcrt-ctx"
|
||||
style={{ left: ctxMenu.x, top: ctxMenu.y }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={() => void copySelection()}>
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" onClick={() => void pasteFromClipboard()}>
|
||||
Paste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
termRef.current?.clear();
|
||||
termRef.current?.write("\x1b[H\x1b[2J");
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -939,6 +939,25 @@ const en = {
|
|||
copied: "Copied to clipboard",
|
||||
copyEmpty: "Terminal is empty",
|
||||
copyFailed: "Copy failed",
|
||||
break: "Break",
|
||||
breakSent: "Break sent",
|
||||
broadcast: "Broadcast",
|
||||
broadcastSent: "Sent to all sessions",
|
||||
postLogin: "Post-login cmds",
|
||||
},
|
||||
favorites: "Favorites",
|
||||
recent: "Recent",
|
||||
favorite: "Toggle favorite",
|
||||
encoding: "Encoding",
|
||||
fontSize: "Font",
|
||||
broadcastPh: "Send to all tabs…",
|
||||
buttonBarHint: "One per line: label|command",
|
||||
postLoginPrompt: "Post-login commands (one per line, this device)",
|
||||
postLoginSaved: "Post-login commands saved",
|
||||
sftp: {
|
||||
refresh: "Refresh",
|
||||
upload: "Upload",
|
||||
uploaded: "Upload complete",
|
||||
},
|
||||
err: {
|
||||
sessionLimit: "Session limit reached; close other terminals first",
|
||||
|
|
@ -946,6 +965,8 @@ const en = {
|
|||
credsIncomplete: "Incomplete username/password on this NE",
|
||||
cliProfile: "UME NEs require a CLI connect profile (default account / hop)",
|
||||
connectFailed: "Device login failed: {{detail}}",
|
||||
sftpHop: "SFTP does not support hop devices yet (direct SSH only)",
|
||||
sftpSsh: "SFTP requires SSH",
|
||||
},
|
||||
},
|
||||
topology: {
|
||||
|
|
|
|||
|
|
@ -936,6 +936,25 @@ const zh = {
|
|||
copied: "已复制到剪贴板",
|
||||
copyEmpty: "终端内容为空",
|
||||
copyFailed: "复制失败",
|
||||
break: "Break",
|
||||
breakSent: "已发送 Break",
|
||||
broadcast: "广播",
|
||||
broadcastSent: "已广播到全部会话",
|
||||
postLogin: "登录后命令",
|
||||
},
|
||||
favorites: "收藏",
|
||||
recent: "最近",
|
||||
favorite: "收藏/取消",
|
||||
encoding: "编码",
|
||||
fontSize: "字号",
|
||||
broadcastPh: "发送到全部标签…",
|
||||
buttonBarHint: "每行:按钮名|命令",
|
||||
postLoginPrompt: "登录后自动执行(每行一条,仅本设备)",
|
||||
postLoginSaved: "登录后命令已保存",
|
||||
sftp: {
|
||||
refresh: "刷新",
|
||||
upload: "上传",
|
||||
uploaded: "上传成功",
|
||||
},
|
||||
err: {
|
||||
sessionLimit: "并发会话已达上限,请先关闭其他终端",
|
||||
|
|
@ -943,6 +962,8 @@ const zh = {
|
|||
credsIncomplete: "网元账号或密码不完整",
|
||||
cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)",
|
||||
connectFailed: "登录设备失败:{{detail}}",
|
||||
sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH",
|
||||
sftpSsh: "SFTP 仅支持 SSH 协议",
|
||||
},
|
||||
},
|
||||
topology: {
|
||||
|
|
|
|||
|
|
@ -2289,7 +2289,8 @@ pre {
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
.webcrt-main__pane .webcrt-term {
|
||||
.webcrt-main__pane .webcrt-term,
|
||||
.webcrt-main__pane .webcrt-term-wrap {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: auto !important;
|
||||
|
|
@ -2332,6 +2333,284 @@ pre {
|
|||
display: none;
|
||||
}
|
||||
|
||||
.webcrt-term-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.webcrt-term-wrap .webcrt-term {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.webcrt-paste-status {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
z-index: 4;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
border: 1px solid #475569;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.webcrt-find {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
background: #1e293b;
|
||||
border-bottom: 1px solid #334155;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.webcrt-find input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #0f172a;
|
||||
border: 1px solid #475569;
|
||||
color: #e2e8f0;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.webcrt-find button {
|
||||
background: #334155;
|
||||
border: 1px solid #475569;
|
||||
color: #e2e8f0;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webcrt-ctx {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
min-width: 120px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #475569;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.webcrt-ctx button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #e2e8f0;
|
||||
text-align: left;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.webcrt-ctx button:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.webcrt-tree__row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.webcrt-tree__fav {
|
||||
flex: 0 0 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.webcrt-tree__fav:hover {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.webcrt-tree__row .webcrt-tree__item {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__pins {
|
||||
max-height: 28%;
|
||||
overflow: auto;
|
||||
border-bottom: 1px solid #334155;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__pin-title {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
padding: 6px 10px 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__prefs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid #334155;
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__prefs label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__prefs select,
|
||||
.webcrt-sidebar__prefs input {
|
||||
background: #0f172a;
|
||||
border: 1px solid #475569;
|
||||
color: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.webcrt-buttonbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
background: #1e293b;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.webcrt-buttonbar__btn {
|
||||
background: #334155;
|
||||
border: 1px solid #475569;
|
||||
color: #e2e8f0;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.webcrt-buttonbar__btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.webcrt-broadcast {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.webcrt-broadcast input {
|
||||
width: 160px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #475569;
|
||||
color: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.webcrt-broadcast button {
|
||||
background: #0ea5e9;
|
||||
border: 0;
|
||||
color: #0f172a;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webcrt-buttonbar-edit {
|
||||
padding: 6px 8px;
|
||||
background: #0f172a;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.webcrt-buttonbar-edit textarea {
|
||||
width: 100%;
|
||||
background: #1e293b;
|
||||
border: 1px solid #475569;
|
||||
color: #e2e8f0;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.webcrt-sftp {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: min(320px, 40%);
|
||||
background: #0f172a;
|
||||
border-left: 1px solid #334155;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.webcrt-sftp__bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.webcrt-sftp__bar input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #1e293b;
|
||||
border: 1px solid #475569;
|
||||
color: #e2e8f0;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.webcrt-sftp__bar button,
|
||||
.webcrt-sftp__upload {
|
||||
background: #334155;
|
||||
border: 1px solid #475569;
|
||||
color: #e2e8f0;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webcrt-sftp__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.webcrt-sftp__list button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webcrt-sftp__list button:hover {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.webcrt-shell {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import {
|
|||
createWebcrtSession,
|
||||
fetchCliTargets,
|
||||
fetchManagedNeById,
|
||||
webcrtSftpDownload,
|
||||
webcrtSftpList,
|
||||
webcrtSftpUpload,
|
||||
webcrtWsUrl,
|
||||
} from "../services/api";
|
||||
import { pageCount } from "../utils/display";
|
||||
|
|
@ -50,6 +53,8 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string,
|
|||
if (raw.includes("credentials_incomplete") || raw.includes("cli_username_required")) return t("webcrt.err.credsIncomplete");
|
||||
if (raw.includes("cli_connect_profile_not_configured")) return t("webcrt.err.cliProfile");
|
||||
if (raw.includes("connect_failed")) return t("webcrt.err.connectFailed", { detail: raw.replace(/^.*connect_failed:/, "") });
|
||||
if (raw.includes("sftp_hop_not_supported")) return t("webcrt.err.sftpHop");
|
||||
if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh");
|
||||
return raw;
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +81,9 @@ export function WebcrtPage() {
|
|||
const [page, setPage] = useState(1);
|
||||
const [tabs, setTabs] = useState<TermTab[]>([]);
|
||||
const [activeTabKey, setActiveTabKey] = useState("");
|
||||
const [sftpOpen, setSftpOpen] = useState(false);
|
||||
const [sftpPath, setSftpPath] = useState(".");
|
||||
const [sftpItems, setSftpItems] = useState<Array<{ name: string; size: number; mtime: number; is_dir: boolean }>>([]);
|
||||
const connectingKeysRef = useRef<Set<string>>(new Set());
|
||||
const tabsRef = useRef<TermTab[]>([]);
|
||||
tabsRef.current = tabs;
|
||||
|
|
@ -145,8 +153,18 @@ export function WebcrtPage() {
|
|||
const rows = Math.max(24, Math.floor((window.innerHeight - 180) / 18));
|
||||
const body =
|
||||
target.source === "ume"
|
||||
? { ume_ne_id: target.ume_ne_id || target.id, cols, rows }
|
||||
: { ne_id: target.id, cols, rows };
|
||||
? {
|
||||
ume_ne_id: target.ume_ne_id || target.id,
|
||||
cols,
|
||||
rows,
|
||||
async_connect: true,
|
||||
}
|
||||
: {
|
||||
ne_id: target.id,
|
||||
cols,
|
||||
rows,
|
||||
async_connect: true,
|
||||
};
|
||||
const sess = await createWebcrtSession(body);
|
||||
const wsUrl = webcrtWsUrl(sess.session_id);
|
||||
updateTab(key, {
|
||||
|
|
@ -228,7 +246,9 @@ export function WebcrtPage() {
|
|||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab) return;
|
||||
try {
|
||||
const text = await termRefs.current.get(tab.key)?.copyAll();
|
||||
const handle = termRefs.current.get(tab.key);
|
||||
const sel = await handle?.copySelection();
|
||||
const text = sel || (await handle?.copyAll());
|
||||
if (text) showOk(t("webcrt.actions.copied"));
|
||||
else showError(t("webcrt.actions.copyEmpty"));
|
||||
} catch {
|
||||
|
|
@ -236,6 +256,22 @@ export function WebcrtPage() {
|
|||
}
|
||||
}, [activeTabKey, showOk, showError, t]);
|
||||
|
||||
const refreshSftp = useCallback(async () => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab) return;
|
||||
try {
|
||||
const body =
|
||||
tab.target.source === "ume"
|
||||
? { ume_ne_id: tab.target.ume_ne_id || tab.target.id, path: sftpPath }
|
||||
: { ne_id: tab.target.id, path: sftpPath };
|
||||
const res = await webcrtSftpList(body);
|
||||
setSftpItems(res.items || []);
|
||||
setSftpPath(res.path || sftpPath);
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
}
|
||||
}, [activeTabKey, showError, sftpPath, t]);
|
||||
|
||||
// Auto-connect from /webcrt?ne_id=...
|
||||
useEffect(() => {
|
||||
if (!presetNeId) return;
|
||||
|
|
@ -292,6 +328,35 @@ export function WebcrtPage() {
|
|||
|
||||
const activeTab = tabs.find((x) => x.key === activeTabKey) || null;
|
||||
|
||||
const renderDeviceRow = (row: CliTargetItem) => {
|
||||
const key = targetKey(row);
|
||||
const tab = tabs.find((x) => x.key === key);
|
||||
const isConnecting = tab?.status === "connecting" || connectingKeysRef.current.has(key);
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
className={`webcrt-tree__item${activeTabKey === key ? " is-active" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => void openTarget(row)}
|
||||
title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
|
||||
>
|
||||
<span className="webcrt-tree__icon" aria-hidden>
|
||||
<ComputerIcon />
|
||||
</span>
|
||||
<span className="webcrt-tree__label">
|
||||
<span className="webcrt-tree__name">{deviceLabel(row)}</span>
|
||||
<span className="webcrt-tree__meta">
|
||||
{row.ip_address || row.source}
|
||||
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
|
||||
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="webcrt-shell">
|
||||
<aside className="webcrt-sidebar">
|
||||
|
|
@ -326,39 +391,7 @@ export function WebcrtPage() {
|
|||
{!targetsQuery.isLoading && items.length === 0 ? (
|
||||
<div className="webcrt-tree__empty">{t("webcrt.empty")}</div>
|
||||
) : null}
|
||||
<ul className="webcrt-tree__list">
|
||||
{items.map((row) => {
|
||||
const key = targetKey(row);
|
||||
const tab = tabs.find((x) => x.key === key);
|
||||
const isConnecting = tab?.status === "connecting" || connectingKeysRef.current.has(key);
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
className={`webcrt-tree__item${activeTabKey === key ? " is-active" : ""}`}
|
||||
onMouseDown={(e) => {
|
||||
// Keep focus off the list button so Enter/Backspace go to the CRT.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={() => void openTarget(row)}
|
||||
title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
|
||||
>
|
||||
<span className="webcrt-tree__icon" aria-hidden>
|
||||
<ComputerIcon />
|
||||
</span>
|
||||
<span className="webcrt-tree__label">
|
||||
<span className="webcrt-tree__name">{deviceLabel(row)}</span>
|
||||
<span className="webcrt-tree__meta">
|
||||
{row.ip_address || row.source}
|
||||
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
|
||||
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<ul className="webcrt-tree__list">{items.map((row) => renderDeviceRow(row))}</ul>
|
||||
</div>
|
||||
<div className="webcrt-sidebar__pager">
|
||||
<span>{t("common.pagerMeta", { total, page, pages })}</span>
|
||||
|
|
@ -435,6 +468,16 @@ export function WebcrtPage() {
|
|||
<span aria-hidden>⧉</span>
|
||||
{t("webcrt.actions.copy")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`webcrt-action-btn${sftpOpen ? " is-active" : ""}`}
|
||||
onClick={() => {
|
||||
setSftpOpen((v) => !v);
|
||||
if (!sftpOpen) void refreshSftp();
|
||||
}}
|
||||
>
|
||||
SFTP
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="webcrt-main__body">
|
||||
|
|
@ -476,6 +519,8 @@ export function WebcrtPage() {
|
|||
if (activeTabKey === tab.key) {
|
||||
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
|
||||
}
|
||||
} else if (state === "connecting") {
|
||||
updateTab(tab.key, { status: "connecting" });
|
||||
} else if (state === "error") updateTab(tab.key, { status: "error" });
|
||||
else if (state === "closed") updateTab(tab.key, { status: "closed" });
|
||||
}}
|
||||
|
|
@ -483,6 +528,89 @@ export function WebcrtPage() {
|
|||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{sftpOpen && activeTab ? (
|
||||
<div className="webcrt-sftp">
|
||||
<div className="webcrt-sftp__bar">
|
||||
<input value={sftpPath} onChange={(e) => setSftpPath(e.target.value)} />
|
||||
<button type="button" onClick={() => void refreshSftp()}>
|
||||
{t("webcrt.sftp.refresh")}
|
||||
</button>
|
||||
<label className="webcrt-sftp__upload">
|
||||
{t("webcrt.sftp.upload")}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !activeTab) return;
|
||||
const remote = `${sftpPath.replace(/\/$/, "")}/${file.name}`.replace(/^\.\//, "");
|
||||
void (async () => {
|
||||
try {
|
||||
const body =
|
||||
activeTab.target.source === "ume"
|
||||
? {
|
||||
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
|
||||
remote_path: remote,
|
||||
file,
|
||||
}
|
||||
: { ne_id: activeTab.target.id, remote_path: remote, file };
|
||||
await webcrtSftpUpload(body);
|
||||
showOk(t("webcrt.sftp.uploaded"));
|
||||
await refreshSftp();
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
}
|
||||
})();
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<ul className="webcrt-sftp__list">
|
||||
{sftpItems.map((it) => (
|
||||
<li key={it.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (it.is_dir) {
|
||||
const next = `${sftpPath.replace(/\/$/, "")}/${it.name}`.replace(/^\.\//, "");
|
||||
setSftpPath(next);
|
||||
window.setTimeout(() => void refreshSftp(), 0);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const body =
|
||||
activeTab.target.source === "ume"
|
||||
? {
|
||||
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
|
||||
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
|
||||
}
|
||||
: {
|
||||
ne_id: activeTab.target.id,
|
||||
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
|
||||
};
|
||||
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));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{it.is_dir ? "📁" : "📄"} {it.name}
|
||||
{!it.is_dir ? ` (${it.size})` : ""}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -459,6 +459,8 @@ export type WebcrtSessionCreateResult = {
|
|||
cols: number;
|
||||
rows: number;
|
||||
ws_path: string;
|
||||
encoding?: string;
|
||||
state?: string;
|
||||
/** True when session used a vendor CLI hop (nested stelnet/telnet). */
|
||||
cli_hop?: boolean;
|
||||
};
|
||||
|
|
@ -468,6 +470,9 @@ export const createWebcrtSession = (body: {
|
|||
ume_ne_id?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
encoding?: string;
|
||||
post_login_commands?: string[];
|
||||
async_connect?: boolean;
|
||||
}) => apiPost<WebcrtSessionCreateResult>("/v1/webcrt/sessions", body);
|
||||
|
||||
export const closeWebcrtSession = (sessionId: string) =>
|
||||
|
|
@ -475,6 +480,66 @@ export const closeWebcrtSession = (sessionId: string) =>
|
|||
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
|
||||
export type WebcrtSftpListResult = {
|
||||
ne_id: string;
|
||||
ne_name: string;
|
||||
path: string;
|
||||
items: Array<{ name: string; size: number; mtime: number; is_dir: boolean }>;
|
||||
};
|
||||
|
||||
export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>
|
||||
apiPost<WebcrtSftpListResult>("/v1/webcrt/sftp/list", body);
|
||||
|
||||
export async function webcrtSftpDownload(body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
path: string;
|
||||
}): Promise<Blob> {
|
||||
const path = "/v1/webcrt/sftp/download";
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("401 unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
export async function webcrtSftpUpload(body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
remote_path: string;
|
||||
file: File;
|
||||
}): Promise<{ ok: boolean; path: string; size: number }> {
|
||||
const path = "/v1/webcrt/sftp/upload";
|
||||
const fd = new FormData();
|
||||
if (body.ne_id) fd.append("ne_id", body.ne_id);
|
||||
if (body.ume_ne_id) fd.append("ume_ne_id", body.ume_ne_id);
|
||||
fd.append("remote_path", body.remote_path);
|
||||
fd.append("file", body.file);
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: fd,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("401 unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as { ok: boolean; path: string; size: number };
|
||||
}
|
||||
|
||||
export const webcrtWsUrl = (sessionId: string): string => {
|
||||
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