Harden WebCRT reconnect, resource use, and SFTP UX.

Reuse detached sessions on reconnect, mount only the active tab, surface backpressure drops, gate SFTP for unsupported targets, and polish attach replay, find/recording performance, and terminal i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 23:50:41 +08:00
parent f6b399e03e
commit d87bdf4be1
13 changed files with 648 additions and 151 deletions

View file

@ -121,5 +121,6 @@ class CliTargetOut(BaseModel):
protocol: str = "" protocol: str = ""
username: str = "" username: str = ""
has_password: bool = False has_password: bool = False
hop_enabled: bool = False
connect_status: str = "unknown" connect_status: str = "unknown"
cli_profile_ready: bool = False cli_profile_ready: bool = False

View file

@ -269,6 +269,10 @@ def list_cli_targets(
offset = (page - 1) * page_size offset = (page - 1) * page_size
kw = str(keyword or "").strip() kw = str(keyword or "").strip()
default_profile = get_default_profile(db)
default_hop = bool(getattr(default_profile, "hop_enabled", False)) if default_profile else False
default_proto = str(getattr(default_profile, "protocol", "") or "ssh") if default_profile else "ssh"
def _managed_item(row: Any, *, list_source: str = "managed") -> dict[str, Any]: def _managed_item(row: Any, *, list_source: str = "managed") -> dict[str, Any]:
return CliTargetOut( return CliTargetOut(
source=list_source, source=list_source,
@ -281,11 +285,20 @@ def list_cli_targets(
protocol=str(getattr(row, "protocol", "") or ""), protocol=str(getattr(row, "protocol", "") or ""),
username=str(getattr(row, "username", "") or ""), username=str(getattr(row, "username", "") or ""),
has_password=bool(str(getattr(row, "password_enc", "") or "").strip()), has_password=bool(str(getattr(row, "password_enc", "") or "").strip()),
hop_enabled=bool(getattr(row, "hop_enabled", False)),
connect_status=str(row.connect_status), connect_status=str(row.connect_status),
cli_profile_ready=True, cli_profile_ready=True,
).model_dump() ).model_dump()
def _ume_item(inv: UmeInventoryNE, ov: UmeCliOverride | None) -> dict[str, Any]: def _ume_item(inv: UmeInventoryNE, ov: UmeCliOverride | None) -> dict[str, Any]:
# UME hop/protocol come from the selected/default CLI profile (SFTP gating).
hop = default_hop
proto = default_proto
if ov and ov.profile_id:
pref = db.get(CliConnectProfile, str(ov.profile_id))
if pref is not None:
hop = bool(pref.hop_enabled)
proto = str(pref.protocol or proto or "ssh")
return CliTargetOut( return CliTargetOut(
source="ume", source="ume",
id=str(inv.ne_id), id=str(inv.ne_id),
@ -294,6 +307,8 @@ def list_cli_targets(
ip_address=str(inv.ip_address or ""), ip_address=str(inv.ip_address or ""),
ne_type=str(inv.ne_type or ""), ne_type=str(inv.ne_type or ""),
vendor=str(inv.vendor or ""), vendor=str(inv.vendor or ""),
protocol=proto,
hop_enabled=hop,
connect_status=str(ov.connect_status if ov else "unknown"), connect_status=str(ov.connect_status if ov else "unknown"),
cli_profile_ready=ready, cli_profile_ready=ready,
).model_dump() ).model_dump()

View file

@ -87,6 +87,8 @@ class Settings(BaseSettings):
webcrt_idle_timeout_sec: int = 1800 webcrt_idle_timeout_sec: int = 1800
webcrt_connect_timeout_sec: int = 90 webcrt_connect_timeout_sec: int = 90
webcrt_attach_timeout_sec: int = 60 webcrt_attach_timeout_sec: int = 60
# Keep device PTY after WS drop so the UI can re-attach (reconnect / remount).
webcrt_detach_grace_sec: int = 120
webcrt_data_dir: str = "data/webcrt" webcrt_data_dir: str = "data/webcrt"
# SSH transport keepalive interval (seconds); 0 disables (default off). # SSH transport keepalive interval (seconds); 0 disables (default off).
webcrt_keepalive_sec: int = 0 webcrt_keepalive_sec: int = 0

View file

@ -22,8 +22,10 @@ from .webcrt_service import (
get_session, get_session,
list_sessions, list_sessions,
mark_attached, mark_attached,
read_session_log_tail,
wait_session_ready, wait_session_ready,
_decode_bytes, _decode_bytes,
_encode_text,
_normalize_encoding, _normalize_encoding,
) )
@ -378,20 +380,32 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
} }
) )
# Replay full login transcript (kept for StrictMode remount / brief reconnect). # First attach: login bootstrap. Later attaches (tab focus / StrictMode): session log tail.
bootstrap = bytes(sess.bootstrap_output or b"") first_attach = not bool(sess.bootstrap_replayed)
if bootstrap: replay_bytes = b""
try: replay_text = ""
if first_attach:
raw_boot = bytes(sess.bootstrap_output or b"")
if raw_boot:
if _normalize_encoding(sess.encoding) != "utf-8": if _normalize_encoding(sess.encoding) != "utf-8":
bootstrap = _decode_bytes(bootstrap, sess.encoding).encode("utf-8", errors="replace") replay_text = _decode_bytes(raw_boot, sess.encoding)
await websocket.send_bytes(bootstrap) replay_bytes = replay_text.encode("utf-8", errors="replace")
else:
replay_bytes = raw_boot
replay_text = _decode_bytes(raw_boot, "utf-8")
sess.bootstrap_replayed = True
else:
replay_text = read_session_log_tail(session_id, max_bytes=49152)
if replay_text:
replay_bytes = _encode_text(replay_text, "utf-8")
if replay_bytes:
try:
await websocket.send_bytes(replay_bytes)
except Exception: except Exception:
try: try:
await websocket.send_json( await websocket.send_json({"type": "stdout", "data": replay_text or ""})
{"type": "stdout", "data": _decode_bytes(bytes(sess.bootstrap_output or b""), sess.encoding)}
)
except Exception: except Exception:
_log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True) _log.debug("webcrt bootstrap/replay send failed session=%s", session_id, exc_info=True)
stop = asyncio.Event() stop = asyncio.Event()
stdin_buf: list[str] = [] stdin_buf: list[str] = []
@ -438,9 +452,29 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
except Exception: except Exception:
return False return False
async def _notify_queue_drops() -> None:
try:
delta = int(sess.out_queue.take_drop_delta() or 0)
except Exception:
delta = 0
if delta <= 0:
return
try:
await websocket.send_json(
{
"type": "status",
"state": "warning",
"message": f"queue_dropped:{delta}",
"dropped": delta,
}
)
except Exception:
pass
while not stop.is_set(): while not stop.is_set():
# Longer block is cheap now (Condition wait); cuts executor churn when idle.
chunk = await loop.run_in_executor( chunk = await loop.run_in_executor(
None, lambda: sess.take_stdout(attach_gen, timeout=0.05) None, lambda: sess.take_stdout(attach_gen, timeout=0.2)
) )
if chunk == "stale": if chunk == "stale":
break break
@ -449,9 +483,11 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
if not await _flush_pending(): if not await _flush_pending():
stop.set() stop.set()
break break
await _notify_queue_drops()
continue continue
if chunk is None: if chunk is None:
await _flush_pending() await _flush_pending()
await _notify_queue_drops()
stop.set() stop.set()
try: try:
await websocket.send_json( await websocket.send_json(
@ -469,6 +505,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
if not await _flush_pending(): if not await _flush_pending():
stop.set() stop.set()
break break
await _notify_queue_drops()
reader_task = asyncio.create_task(pump_stdout()) reader_task = asyncio.create_task(pump_stdout())
if sess.needs_live_prompt: if sess.needs_live_prompt:
@ -552,11 +589,12 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
await reader_task await reader_task
except Exception: except Exception:
pass pass
# Keep device session briefly so React remount / blip can re-attach. # Keep device session so UI reconnect / remount can re-attach.
if get_session(session_id) is not None: if get_session(session_id) is not None:
grace = float(getattr(settings, "webcrt_detach_grace_sec", 120) or 120)
detach_session( detach_session(
session_id, session_id,
grace_sec=8.0, grace_sec=max(8.0, grace),
client=_client_label(websocket=websocket), client=_client_label(websocket=websocket),
attach_gen=attach_gen, attach_gen=attach_gen,
) )

View file

@ -34,8 +34,6 @@ _log = logging.getLogger("netx.webcrt")
_sessions_lock = threading.Lock() _sessions_lock = threading.Lock()
_sessions: dict[str, "WebcrtSession"] = {} _sessions: dict[str, "WebcrtSession"] = {}
_reaper_started = False _reaper_started = False
# Sentinel for take_stdout timeout (distinct from device EOF None).
_STDOUT_MISSING = object()
# Network device CLI key rewrites (SecureCRT-like). # Network device CLI key rewrites (SecureCRT-like).
# - Backspace: DEL(0x7f) -> BS(0x08) # - Backspace: DEL(0x7f) -> BS(0x08)
@ -347,11 +345,12 @@ class _BoundedByteQueue:
def __init__(self, maxsize: int = 2000) -> None: def __init__(self, maxsize: int = 2000) -> None:
self._q: queue.Queue[bytes | None] = queue.Queue() self._q: queue.Queue[bytes | None] = queue.Queue()
self._max = max(8, int(maxsize or 2000)) self._max = max(8, int(maxsize or 2000))
self._lock = threading.Lock() self._cond = threading.Condition()
self.dropped = 0 self.dropped = 0
self._reported = 0
def put(self, item: bytes | None) -> None: def put(self, item: bytes | None) -> None:
with self._lock: with self._cond:
while self._q.qsize() >= self._max: while self._q.qsize() >= self._max:
try: try:
self._q.get_nowait() self._q.get_nowait()
@ -359,15 +358,38 @@ class _BoundedByteQueue:
except queue.Empty: except queue.Empty:
break break
self._q.put(item) self._q.put(item)
self._cond.notify()
def put_nowait(self, item: bytes | None) -> None: def put_nowait(self, item: bytes | None) -> None:
self.put(item) self.put(item)
def get_nowait(self) -> bytes | None: def get_nowait(self) -> bytes | None:
return self._q.get_nowait() with self._cond:
return self._q.get_nowait()
def get(self, timeout: float = 0.25) -> bytes | None:
"""Block until a chunk is available or timeout (raises queue.Empty)."""
deadline = time.time() + max(0.0, float(timeout))
with self._cond:
while self._q.empty():
remaining = deadline - time.time()
if remaining <= 0:
raise queue.Empty
self._cond.wait(timeout=remaining)
return self._q.get_nowait()
def qsize(self) -> int: def qsize(self) -> int:
return self._q.qsize() with self._cond:
return self._q.qsize()
def take_drop_delta(self) -> int:
"""Return newly dropped chunk count since last call (for client notice)."""
with self._cond:
delta = int(self.dropped) - int(self._reported)
if delta <= 0:
return 0
self._reported = int(self.dropped)
return delta
def _utc_now() -> datetime: def _utc_now() -> datetime:
@ -390,6 +412,33 @@ def _session_log_path(session_id: str) -> Path:
return folder / f"{session_id}.log" return folder / f"{session_id}.log"
def read_session_log_tail(session_id: str, *, max_bytes: int = 49152) -> str:
"""Best-effort UTF-8 tail of the on-disk session transcript (for WS re-attach)."""
path = _session_log_path(session_id)
try:
if not path.is_file():
return ""
size = path.stat().st_size
take = max(1024, min(int(max_bytes or 49152), 256 * 1024))
with path.open("rb") as fh:
if size > take:
fh.seek(size - take)
raw = fh.read()
# Drop partial first line after seek.
nl = raw.find(b"\n")
if 0 <= nl < len(raw) - 1:
raw = raw[nl + 1 :]
else:
raw = fh.read()
text = raw.decode("utf-8", errors="replace")
# Strip header comment lines from the visible replay.
lines = [ln for ln in text.splitlines(keepends=True) if not ln.startswith("# session=")]
return "".join(lines)
except Exception:
_log.debug("webcrt session log tail failed session=%s", session_id, exc_info=True)
return ""
def _audit(event: str, **fields: Any) -> None: def _audit(event: str, **fields: Any) -> None:
record = {"ts": _utc_iso(), "event": event, **fields} record = {"ts": _utc_iso(), "event": event, **fields}
try: try:
@ -428,6 +477,8 @@ class WebcrtSession:
connect_started_at: float = field(default_factory=time.time) connect_started_at: float = field(default_factory=time.time)
connect_finished_at: float | None = None connect_finished_at: float | None = None
bootstrap_output: bytes = b"" bootstrap_output: bytes = b""
# First WS attach gets login bootstrap; later attaches prefer session-log tail.
bootstrap_replayed: bool = False
needs_live_prompt: bool = True needs_live_prompt: bool = True
# React StrictMode remounts open a second WS before the first fully tears down. # React StrictMode remounts open a second WS before the first fully tears down.
# Only the newest attach_gen may consume out_queue / mark detach. # Only the newest attach_gen may consume out_queue / mark detach.
@ -499,19 +550,20 @@ class WebcrtSession:
with self._stdout_lock: with self._stdout_lock:
if attach_gen != self.attach_gen: if attach_gen != self.attach_gen:
return "stale" return "stale"
try: remaining = deadline - time.time()
chunk = self.out_queue.get_nowait() if remaining <= 0:
except queue.Empty:
chunk = _STDOUT_MISSING
if chunk is not _STDOUT_MISSING:
if attach_gen != self.attach_gen:
# Put back including EOF sentinel so the new owner still sees close.
self.out_queue.put(chunk)
return "stale"
return chunk # bytes | None
if time.time() >= deadline:
return "empty" return "empty"
time.sleep(0.005) # Slice waits so we can notice attach_gen bumps without busy-spinning.
try:
chunk = self.out_queue.get(timeout=min(0.05, remaining))
except queue.Empty:
continue
with self._stdout_lock:
if attach_gen != self.attach_gen:
# Put back including EOF sentinel so the new owner still sees close.
self.out_queue.put(chunk)
return "stale"
return chunk # bytes | None
def write_stdin(self, data: str) -> None: def write_stdin(self, data: str) -> None:
if self.closed or self.conn is None: if self.closed or self.conn is None:
@ -803,10 +855,14 @@ def _reap_sessions() -> None:
if sess.detach_deadline is not None: if sess.detach_deadline is not None:
if now >= sess.detach_deadline: if now >= sess.detach_deadline:
to_close.append((sess, "detach_timeout")) to_close.append((sess, "detach_timeout"))
elif (now - sess.created_at) > attach: else:
to_close.append((sess, "attach_timeout")) # Start attach clock after connect finishes (not HTTP create time),
elif (now - sess.last_activity) > idle: # so slow auth + UI mount does not race attach_timeout.
to_close.append((sess, "idle_timeout")) anchor = float(sess.connect_finished_at or sess.created_at or now)
if (now - anchor) > attach:
to_close.append((sess, "attach_timeout"))
elif (now - sess.last_activity) > idle:
to_close.append((sess, "idle_timeout"))
for sess in to_nudge: for sess in to_nudge:
try: try:
# Touch without changing visible prompt when payload is empty/null-ish. # Touch without changing visible prompt when payload is empty/null-ish.

View file

@ -436,11 +436,15 @@ class WebcrtUpsertAndTargetsTests(unittest.TestCase):
self.assertEqual(webcrt["items"][0]["source"], "webcrt") self.assertEqual(webcrt["items"][0]["source"], "webcrt")
self.assertEqual(webcrt["items"][0]["ip_address"], "10.7.7.7") self.assertEqual(webcrt["items"][0]["ip_address"], "10.7.7.7")
self.assertTrue(webcrt["items"][0]["has_password"]) self.assertTrue(webcrt["items"][0]["has_password"])
self.assertIn("hop_enabled", webcrt["items"][0])
self.assertFalse(webcrt["items"][0]["hop_enabled"])
managed = list_cli_targets(self.db, source="managed", page=1, page_size=50) managed = list_cli_targets(self.db, source="managed", page=1, page_size=50)
ips = {x["ip_address"] for x in managed["items"]} ips = {x["ip_address"] for x in managed["items"]}
self.assertIn("10.7.7.8", ips) self.assertIn("10.7.7.8", ips)
self.assertNotIn("10.7.7.7", ips) self.assertNotIn("10.7.7.7", ips)
zte = next(x for x in managed["items"] if x["ip_address"] == "10.7.7.8")
self.assertIn("hop_enabled", zte)
def test_upsert_session_host_telnet_no_password(self): def test_upsert_session_host_telnet_no_password(self):
out, action = upsert_webcrt_session_host( out, action = upsert_webcrt_session_host(

View file

@ -490,6 +490,38 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertFalse(sess.attached) self.assertFalse(sess.attached)
svc.close_session("race", reason="test") svc.close_session("race", reason="test")
@patch.object(svc, "_audit")
def test_detach_grace_keeps_session_until_deadline(self, _mock_audit: MagicMock) -> None:
conn = _FakeConn()
sess = svc.WebcrtSession(
session_id="grace",
ne_id="ne1",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
conn=conn, # type: ignore[arg-type]
)
sess.attached = True
with svc._sessions_lock:
svc._sessions["grace"] = sess
svc.detach_session("grace", grace_sec=120.0, attach_gen=0)
self.assertIsNotNone(svc.get_session("grace"))
self.assertFalse(sess.attached)
self.assertIsNotNone(sess.detach_deadline)
# Still within grace — reaper must not close.
with patch.object(svc.settings, "webcrt_attach_timeout_sec", 99999):
with patch.object(svc.settings, "webcrt_idle_timeout_sec", 99999):
svc._reap_sessions()
self.assertIsNotNone(svc.get_session("grace"))
# Expire grace.
sess.detach_deadline = time.time() - 1
with patch.object(svc.settings, "webcrt_attach_timeout_sec", 99999):
with patch.object(svc.settings, "webcrt_idle_timeout_sec", 99999):
svc._reap_sessions()
self.assertIsNone(svc.get_session("grace"))
@patch.object(svc, "_audit") @patch.object(svc, "_audit")
def test_attach_timeout_reaper(self, _mock_audit: MagicMock) -> None: def test_attach_timeout_reaper(self, _mock_audit: MagicMock) -> None:
conn = _FakeConn() conn = _FakeConn()
@ -511,6 +543,47 @@ class WebcrtServiceTests(unittest.TestCase):
svc._reap_sessions() svc._reap_sessions()
self.assertIsNone(svc.get_session("stale")) self.assertIsNone(svc.get_session("stale"))
@patch.object(svc, "_audit")
def test_attach_timeout_uses_connect_finished_at(self, _mock_audit: MagicMock) -> None:
"""Slow connect should not burn the attach window from HTTP create time."""
conn = _FakeConn()
sess = svc.WebcrtSession(
session_id="late",
ne_id="ne1",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
conn=conn, # type: ignore[arg-type]
)
sess.state = "ready"
sess.created_at = time.time() - 120
sess.connect_finished_at = time.time() - 5
with svc._sessions_lock:
svc._sessions["late"] = sess
with patch.object(svc.settings, "webcrt_attach_timeout_sec", 30):
with patch.object(svc.settings, "webcrt_idle_timeout_sec", 99999):
svc._reap_sessions()
self.assertIsNotNone(svc.get_session("late"))
svc.close_session("late", reason="test")
def test_session_log_tail_strips_header(self) -> None:
sid = "tailtest"
path = svc._session_log_path(sid)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("# session=tailtest ne=x ip=1.2.3.4 ts=now\nR2#\nshow ver\n", encoding="utf-8")
try:
text = svc.read_session_log_tail(sid, max_bytes=4096)
self.assertNotIn("# session=", text)
self.assertIn("R2#", text)
self.assertIn("show ver", text)
finally:
try:
path.unlink()
except OSError:
pass
@patch.object(svc, "_audit") @patch.object(svc, "_audit")
def test_idle_timeout_reaper(self, _mock_audit: MagicMock) -> None: def test_idle_timeout_reaper(self, _mock_audit: MagicMock) -> None:
conn = _FakeConn() conn = _FakeConn()
@ -615,12 +688,77 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertGreaterEqual(q.dropped, 2) self.assertGreaterEqual(q.dropped, 2)
first = q.get_nowait() first = q.get_nowait()
self.assertEqual(first, b"2") self.assertEqual(first, b"2")
delta = q.take_drop_delta()
self.assertGreaterEqual(delta, 2)
self.assertEqual(q.take_drop_delta(), 0)
# Further drops report only the new delta.
for i in range(20):
q.put(str(i).encode())
self.assertGreater(q.take_drop_delta(), 0)
self.assertEqual(q.take_drop_delta(), 0)
def test_bounded_queue_blocking_get(self) -> None:
import threading
q = svc._BoundedByteQueue(maxsize=8)
box: dict[str, bytes | None] = {"v": None}
def _reader() -> None:
box["v"] = q.get(timeout=1.0)
t = threading.Thread(target=_reader, daemon=True)
t.start()
time.sleep(0.05)
q.put(b"wake")
t.join(timeout=1.0)
self.assertEqual(box["v"], b"wake")
def test_normalize_encoding(self) -> None: def test_normalize_encoding(self) -> None:
self.assertEqual(svc._normalize_encoding("GBK"), "gbk") self.assertEqual(svc._normalize_encoding("GBK"), "gbk")
self.assertEqual(svc._normalize_encoding("utf8"), "utf-8") self.assertEqual(svc._normalize_encoding("utf8"), "utf-8")
self.assertEqual(svc._encode_text("测", "gbk")[:1], b"\xb2") self.assertEqual(svc._encode_text("测", "gbk")[:1], b"\xb2")
def test_sftp_requires_direct_ssh(self) -> None:
from netx_api.webcrt_sftp import _require_ssh_direct
with self.assertRaises(HTTPException) as telnet_cm:
_require_ssh_direct({"protocol": "telnet"}, {"protocol": "telnet"})
self.assertEqual(telnet_cm.exception.status_code, 400)
self.assertEqual(telnet_cm.exception.detail, "sftp_requires_ssh")
with self.assertRaises(HTTPException) as hop_cm:
_require_ssh_direct({"protocol": "ssh", "hop_enabled": True}, {"protocol": "ssh"})
self.assertEqual(hop_cm.exception.status_code, 400)
self.assertEqual(hop_cm.exception.detail, "sftp_hop_not_supported")
# Direct SSH is allowed (no raise).
_require_ssh_direct({"protocol": "ssh", "hop_enabled": False}, {"protocol": "ssh"})
@patch.object(svc, "_audit")
def test_reattach_clears_detach_deadline(self, _mock_audit: MagicMock) -> None:
conn = _FakeConn()
sess = svc.WebcrtSession(
session_id="rejoin",
ne_id="ne1",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
conn=conn, # type: ignore[arg-type]
)
with svc._sessions_lock:
svc._sessions["rejoin"] = sess
_, gen1 = svc.mark_attached("rejoin")
out = svc.detach_session("rejoin", grace_sec=120.0, attach_gen=gen1)
self.assertTrue(out.get("detached"))
self.assertIsNotNone(sess.detach_deadline)
_, gen2 = svc.mark_attached("rejoin")
self.assertEqual(gen2, gen1 + 1)
self.assertTrue(sess.attached)
self.assertIsNone(sess.detach_deadline)
svc.close_session("rejoin", reason="test")
def test_linux_telnet_maps_to_generic_telnet(self) -> None: def test_linux_telnet_maps_to_generic_telnet(self) -> None:
from netx_api.ne_netmiko import normalize_netmiko_device_type from netx_api.ne_netmiko import normalize_netmiko_device_type
from netx_api.ne_session_factory import _netmiko_driver_class from netx_api.ne_session_factory import _netmiko_driver_class

View file

@ -2,6 +2,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "re
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useI18n } from "../i18n";
import { import {
applyKeywordHighlight, applyKeywordHighlight,
type KeywordHighlightConfig, type KeywordHighlightConfig,
@ -84,6 +85,28 @@ function serializeTerminal(term: Terminal): string {
return lines.join("\n").replace(/\s+$/g, ""); return lines.join("\n").replace(/\s+$/g, "");
} }
/** Line-wise search — avoids serializing the full 10k scrollback on every Find. */
function findBufferLine(
term: Terminal,
query: string,
startLine: number,
direction: 1 | -1,
): number {
const needle = String(query || "").toLowerCase();
if (!needle) return -1;
const buf = term.buffer.active;
const len = buf.length;
if (len <= 0) return -1;
const start = ((Math.trunc(startLine) % len) + len) % len;
for (let step = 0; step < len; step += 1) {
const i = direction > 0 ? (start + step) % len : (start - step + len) % len;
const line = buf.getLine(i);
if (!line) continue;
if (line.translateToString(true).toLowerCase().includes(needle)) return i;
}
return -1;
}
function isSidebarSearchTarget(target: EventTarget | null): boolean { function isSidebarSearchTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false; if (!(target instanceof HTMLElement)) return false;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") { if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") {
@ -159,6 +182,9 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}, },
ref, ref,
) { ) {
const { t } = useI18n();
const tRef = useRef(t);
tRef.current = t;
const resolvedColors: TermColors = termColors || const resolvedColors: TermColors = termColors ||
(themeName === "light" (themeName === "light"
? { background: "#ffffff", foreground: "#000000" } ? { background: "#ffffff", foreground: "#000000" }
@ -167,7 +193,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null); const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const findIndexRef = useRef(0); const findLineRef = useRef(-1);
const onStatusRef = useRef(onStatus); const onStatusRef = useRef(onStatus);
const onReadyRef = useRef(onReady); const onReadyRef = useRef(onReady);
const onStdoutRef = useRef(onStdout); const onStdoutRef = useRef(onStdout);
@ -346,29 +372,19 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
findNext: (q: string) => { findNext: (q: string) => {
const term = termRef.current; const term = termRef.current;
if (!term || !q) return; if (!term || !q) return;
const text = serializeTerminal(term).toLowerCase(); const hit = findBufferLine(term, q, findLineRef.current + 1, 1);
const needle = q.toLowerCase(); if (hit >= 0) {
let idx = text.indexOf(needle, findIndexRef.current + 1); findLineRef.current = hit;
if (idx < 0) idx = text.indexOf(needle); term.scrollToLine(Math.max(0, hit - 2));
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) => { findPrevious: (q: string) => {
const term = termRef.current; const term = termRef.current;
if (!term || !q) return; if (!term || !q) return;
const text = serializeTerminal(term).toLowerCase(); const hit = findBufferLine(term, q, Math.max(0, findLineRef.current) - 1, -1);
const needle = q.toLowerCase(); if (hit >= 0) {
const before = text.slice(0, Math.max(0, findIndexRef.current)); findLineRef.current = hit;
let idx = before.lastIndexOf(needle); term.scrollToLine(Math.max(0, hit - 2));
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));
} }
}, },
})); }));
@ -479,9 +495,24 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
maybeFocus(); maybeFocus();
return; return;
} }
if (msg.state === "warning") {
const m = String(msg.message || "");
const dropMatch = /^queue_dropped:(\d+)/i.exec(m);
if (dropMatch) {
term.writeln(
`\r\n\x1b[33m${tRef.current("webcrt.term.outputTruncated", { count: dropMatch[1] })}\x1b[0m`,
);
}
return;
}
if (msg.state === "closed" || msg.state === "error") { if (msg.state === "closed" || msg.state === "error") {
const detail = msg.message ? `: ${msg.message}` : ""; const detail = msg.message ? `: ${msg.message}` : "";
term.writeln(`\r\n\x1b[33m[session ${msg.state}${detail}]\x1b[0m`); term.writeln(
`\r\n\x1b[33m${tRef.current("webcrt.term.sessionStatus", {
state: String(msg.state || ""),
detail,
})}\x1b[0m`,
);
} }
return; return;
} }
@ -494,7 +525,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
ws.onerror = () => { ws.onerror = () => {
if (!isActiveSocket()) return; if (!isActiveSocket()) return;
onStatusRef.current?.("error", "websocket_error"); onStatusRef.current?.("error", "websocket_error");
term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m"); term.writeln(`\r\n\x1b[31m${tRef.current("webcrt.term.wsError")}\x1b[0m`);
}; };
ws.onclose = (ev) => { ws.onclose = (ev) => {
@ -502,7 +533,9 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
if (!isActiveSocket()) return; if (!isActiveSocket()) return;
onStatusRef.current?.("closed", `websocket_closed:${ev.code}`); onStatusRef.current?.("closed", `websocket_closed:${ev.code}`);
if (!ev.wasClean) { if (!ev.wasClean) {
term.writeln(`\r\n\x1b[33m[websocket closed code=${ev.code}]\x1b[0m`); term.writeln(
`\r\n\x1b[33m${tRef.current("webcrt.term.wsClosed", { code: ev.code })}\x1b[0m`,
);
} }
}; };
@ -637,7 +670,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
<div className="webcrt-term-wrap"> <div className="webcrt-term-wrap">
{pasteStatus ? ( {pasteStatus ? (
<div className="webcrt-paste-status" role="status" aria-live="polite"> <div className="webcrt-paste-status" role="status" aria-live="polite">
粘贴中 {pasteStatus.done}/{pasteStatus.total} 行… {t("webcrt.term.pasting", { done: pasteStatus.done, total: pasteStatus.total })}
</div> </div>
) : null} ) : null}
{findOpen ? ( {findOpen ? (
@ -646,30 +679,26 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
ref={findInputRef} ref={findInputRef}
type="search" type="search"
value={findQuery} value={findQuery}
placeholder="Find…" placeholder={t("webcrt.term.findPh")}
onChange={(e) => setFindQuery(e.target.value)} aria-label={t("webcrt.term.findPh")}
onChange={(e) => {
setFindQuery(e.target.value);
findLineRef.current = -1;
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
const term = termRef.current; const term = termRef.current;
if (!term || !findQuery) return; if (!term || !findQuery) return;
const text = serializeTerminal(term).toLowerCase(); const hit = findBufferLine(
const needle = findQuery.toLowerCase(); term,
if (e.shiftKey) { findQuery,
const before = text.slice(0, Math.max(0, findIndexRef.current)); e.shiftKey ? Math.max(0, findLineRef.current) - 1 : findLineRef.current + 1,
let idx = before.lastIndexOf(needle); e.shiftKey ? -1 : 1,
if (idx < 0) idx = text.lastIndexOf(needle); );
if (idx >= 0) { if (hit >= 0) {
findIndexRef.current = idx; findLineRef.current = hit;
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3)); term.scrollToLine(Math.max(0, hit - 2));
}
} 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") { } else if (e.key === "Escape") {
setFindOpen(false); setFindOpen(false);
@ -679,17 +708,15 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
/> />
<button <button
type="button" type="button"
aria-label={t("webcrt.term.findPrev")}
title={t("webcrt.term.findPrev")}
onClick={() => { onClick={() => {
const term = termRef.current; const term = termRef.current;
if (!term || !findQuery) return; if (!term || !findQuery) return;
const text = serializeTerminal(term).toLowerCase(); const hit = findBufferLine(term, findQuery, Math.max(0, findLineRef.current) - 1, -1);
const needle = findQuery.toLowerCase(); if (hit >= 0) {
const before = text.slice(0, Math.max(0, findIndexRef.current)); findLineRef.current = hit;
let idx = before.lastIndexOf(needle); term.scrollToLine(Math.max(0, hit - 2));
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));
} }
}} }}
> >
@ -697,16 +724,15 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
</button> </button>
<button <button
type="button" type="button"
aria-label={t("webcrt.term.findNext")}
title={t("webcrt.term.findNext")}
onClick={() => { onClick={() => {
const term = termRef.current; const term = termRef.current;
if (!term || !findQuery) return; if (!term || !findQuery) return;
const text = serializeTerminal(term).toLowerCase(); const hit = findBufferLine(term, findQuery, findLineRef.current + 1, 1);
const needle = findQuery.toLowerCase(); if (hit >= 0) {
let idx = text.indexOf(needle, findIndexRef.current + 1); findLineRef.current = hit;
if (idx < 0) idx = text.indexOf(needle); term.scrollToLine(Math.max(0, hit - 2));
if (idx >= 0) {
findIndexRef.current = idx;
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
} }
}} }}
> >
@ -714,6 +740,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
</button> </button>
<button <button
type="button" type="button"
aria-label={t("webcrt.term.findClose")}
title={t("webcrt.term.findClose")}
onClick={() => { onClick={() => {
setFindOpen(false); setFindOpen(false);
focusTerminal(); focusTerminal();
@ -738,10 +766,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
<button type="button" onClick={() => void copySelection()}> <button type="button" onClick={() => void copySelection()}>
Copy {t("webcrt.term.copy")}
</button> </button>
<button type="button" onClick={() => void pasteFromClipboard()}> <button type="button" onClick={() => void pasteFromClipboard()}>
Paste {t("webcrt.term.paste")}
</button> </button>
<button <button
type="button" type="button"
@ -751,7 +779,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
setCtxMenu(null); setCtxMenu(null);
}} }}
> >
Clear {t("webcrt.term.clear")}
</button> </button>
</div> </div>
) : null} ) : null}

View file

@ -1040,9 +1040,26 @@ const en = {
postLoginPrompt: "Post-login commands (one per line, this device)", postLoginPrompt: "Post-login commands (one per line, this device)",
postLoginSaved: "Post-login commands saved", postLoginSaved: "Post-login commands saved",
sftp: { sftp: {
title: "SFTP",
refresh: "Refresh", refresh: "Refresh",
up: "Up",
upload: "Upload", upload: "Upload",
uploaded: "Upload complete", uploaded: "Upload complete",
dir: "dir",
},
term: {
findPh: "Find…",
findPrev: "Previous",
findNext: "Next",
findClose: "Close find",
copy: "Copy",
paste: "Paste",
clear: "Clear",
pasting: "Pasting {{done}}/{{total}} lines…",
wsError: "[WebSocket error]",
wsClosed: "[WebSocket closed code={{code}}]",
sessionStatus: "[session {{state}}{{detail}}]",
outputTruncated: "[output truncated: {{count}} chunk(s) dropped]",
}, },
err: { err: {
deviceType: "Unsupported device type", deviceType: "Unsupported device type",
@ -1055,6 +1072,9 @@ const en = {
connectFailed: "Device login failed: {{detail}}", connectFailed: "Device login failed: {{detail}}",
sftpHop: "SFTP does not support hop devices yet (direct SSH only)", sftpHop: "SFTP does not support hop devices yet (direct SSH only)",
sftpSsh: "SFTP requires SSH", sftpSsh: "SFTP requires SSH",
sftpNeedPassword: "SFTP needs a saved SSH password (one-shot dialog passwords are not reused for files)",
queueDropped: "Terminal output too fast; about {{count}} chunk(s) dropped — transcript may be incomplete",
websocket: "WebSocket connection failed",
}, },
}, },
topology: { topology: {

View file

@ -1037,9 +1037,26 @@ const zh = {
postLoginPrompt: "登录后自动执行(每行一条,仅本设备)", postLoginPrompt: "登录后自动执行(每行一条,仅本设备)",
postLoginSaved: "登录后命令已保存", postLoginSaved: "登录后命令已保存",
sftp: { sftp: {
title: "SFTP",
refresh: "刷新", refresh: "刷新",
up: "上级",
upload: "上传", upload: "上传",
uploaded: "上传成功", uploaded: "上传成功",
dir: "目录",
},
term: {
findPh: "查找…",
findPrev: "上一个",
findNext: "下一个",
findClose: "关闭查找",
copy: "复制",
paste: "粘贴",
clear: "清空",
pasting: "粘贴中 {{done}}/{{total}} 行…",
wsError: "[WebSocket 错误]",
wsClosed: "[WebSocket 已关闭 code={{code}}]",
sessionStatus: "[会话 {{state}}{{detail}}]",
outputTruncated: "[输出过快,已丢弃 {{count}} 段]",
}, },
err: { err: {
deviceType: "不支持的设备类型", deviceType: "不支持的设备类型",
@ -1052,6 +1069,9 @@ const zh = {
connectFailed: "登录设备失败:{{detail}}", connectFailed: "登录设备失败:{{detail}}",
sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH", sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH",
sftpSsh: "SFTP 仅支持 SSH 协议", sftpSsh: "SFTP 仅支持 SSH 协议",
sftpNeedPassword: "SFTP 需要已保存的 SSH 密码(会话弹窗密码不会用于文件传输)",
queueDropped: "终端输出过快,已丢弃约 {{count}} 段,内容可能不完整",
websocket: "WebSocket 连接失败",
}, },
}, },
topology: { topology: {

View file

@ -1907,10 +1907,10 @@ pre {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 28px; height: 28px;
padding: 0 10px; padding: 0;
border: 1px solid #b8c4d2; border: 0;
border-radius: 6px; border-radius: 0;
background: #fff; background: transparent;
color: var(--nm-brand); color: var(--nm-brand);
font-size: 12px; font-size: 12px;
font-weight: 650; font-weight: 650;
@ -1920,16 +1920,17 @@ pre {
} }
.webcrt-sidebar__new-btn:hover { .webcrt-sidebar__new-btn:hover {
background: #e8f0fe; background: transparent;
border-color: #9eb0c6;
color: var(--nm-accent); color: var(--nm-accent);
text-decoration: underline;
} }
.app-main .webcrt-sidebar__new-btn { .app-main .webcrt-sidebar__new-btn {
border: 1px solid #b8c4d2; border: 0;
box-shadow: none; box-shadow: none;
height: 28px; height: 28px;
border-radius: 6px; border-radius: 0;
background: transparent;
} }
.webcrt-menubar__item { .webcrt-menubar__item {
@ -1941,9 +1942,9 @@ pre {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
height: 28px; height: 28px;
padding: 0 8px; padding: 0;
border: 0; border: 0;
border-radius: 6px; border-radius: 0;
background: transparent; background: transparent;
color: #475569; color: #475569;
font-size: 12px; font-size: 12px;
@ -1955,15 +1956,17 @@ pre {
.webcrt-menubar__btn:hover, .webcrt-menubar__btn:hover,
.webcrt-menubar__btn.is-open { .webcrt-menubar__btn.is-open {
background: #dbe4f0; background: transparent;
color: var(--nm-brand); color: var(--nm-brand);
text-decoration: underline;
} }
.app-main .webcrt-menubar__btn { .app-main .webcrt-menubar__btn {
border: 0; border: 0;
box-shadow: none; box-shadow: none;
height: 28px; height: 28px;
border-radius: 6px; border-radius: 0;
background: transparent;
} }
.webcrt-menubar__menu { .webcrt-menubar__menu {

View file

@ -36,6 +36,9 @@ 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;
const KEEPALIVE_OPTIONS = [0, 15, 30, 60, 120] as const; const KEEPALIVE_OPTIONS = [0, 15, 30, 60, 120] as const;
/** Compact recording chunks before they grow unbounded (join + trim). */
const LOG_COMPACT_CHUNKS = 1500;
const LOG_MAX_CHARS = 8 * 1024 * 1024;
type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom"; type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom";
@ -109,6 +112,8 @@ type TermTab = {
errorMessage?: string; errorMessage?: string;
recording: boolean; recording: boolean;
encoding: string; encoding: string;
/** From session create — nested CLI hop cannot use SFTP. */
cliHop?: boolean;
}; };
type TabMenuState = { key: string; x: number; y: number }; type TabMenuState = { key: string; x: number; y: number };
@ -282,6 +287,58 @@ function isSshAuthFailure(err: unknown): boolean {
); );
} }
/** Inventory SSH (managed / webcrt Quick Connect). UME uses shared CLI profile — no per-NE popup. */
function isInventorySsh(target: Pick<CliTargetItem, "source" | "protocol">): boolean {
const src = String(target.source || "").toLowerCase();
if (src !== "webcrt" && src !== "managed") return false;
return String(target.protocol || "ssh").toLowerCase() !== "telnet";
}
function isSessionGoneError(err: unknown): boolean {
const raw = String(err).toLowerCase();
return (
raw.includes("webcrt_session_not_found") ||
raw.includes("session_not_found") ||
raw.includes("4404")
);
}
function isDeviceClosedMessage(err: unknown): boolean {
const raw = String(err).toLowerCase();
return (
raw.includes("device_closed") ||
raw.includes("client_close") ||
raw.includes("client_delete") ||
raw.includes("cli_hop_return") ||
raw.includes("idle_timeout") ||
raw.includes("detach_timeout") ||
raw.includes("attach_timeout") ||
raw.includes("session closed")
);
}
/** SFTP is direct SSH only; returns i18n key when unavailable. */
function sftpUnavailableReason(
tab: Pick<TermTab, "target" | "cliHop">,
): "webcrt.err.sftpSsh" | "webcrt.err.sftpHop" | "webcrt.err.sftpNeedPassword" | null {
const proto = String(tab.target.protocol || "ssh").toLowerCase();
if (proto === "telnet") return "webcrt.err.sftpSsh";
if (tab.cliHop || tab.target.hop_enabled) return "webcrt.err.sftpHop";
// Inventory/quick-connect SFTP opens a fresh Paramiko session from saved DB creds.
if (tab.target.source !== "ume" && !tab.target.has_password) return "webcrt.err.sftpNeedPassword";
return null;
}
function sftpParentPath(path: string): string {
const cur = String(path || ".").replace(/\\/g, "/").replace(/\/+$/, "") || ".";
if (cur === "." || cur === "/") return cur === "/" ? "/" : ".";
const parts = cur.split("/").filter((p, i) => p || i === 0);
if (parts.length <= 1) return cur.startsWith("/") ? "/" : ".";
parts.pop();
const parent = parts.join("/") || (cur.startsWith("/") ? "/" : ".");
return parent || ".";
}
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;
@ -310,6 +367,7 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string,
} }
if (raw.includes("sftp_hop_not_supported")) return t("webcrt.err.sftpHop"); if (raw.includes("sftp_hop_not_supported")) return t("webcrt.err.sftpHop");
if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh"); if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh");
if (raw.includes("websocket_error")) return t("webcrt.err.websocket");
return raw; return raw;
} }
@ -380,7 +438,9 @@ export function WebcrtPage() {
const tabsRef = useRef<TermTab[]>([]); const tabsRef = useRef<TermTab[]>([]);
tabsRef.current = tabs; tabsRef.current = tabs;
const termRefs = useRef<Map<string, WebTerminalHandle>>(new Map()); const termRefs = useRef<Map<string, WebTerminalHandle>>(new Map());
const logBuffersRef = useRef<Map<string, string>>(new Map()); /** Chunk lists avoid O(n²) string append while recording. */
const logBuffersRef = useRef<Map<string, string[]>>(new Map());
const lastQueueDropToastAtRef = useRef(0);
const optionsMenuRef = useRef<HTMLDivElement | null>(null); const optionsMenuRef = useRef<HTMLDivElement | null>(null);
const tabMenuRef = useRef<HTMLDivElement | null>(null); const tabMenuRef = useRef<HTMLDivElement | null>(null);
const treeMenuRef = useRef<HTMLDivElement | null>(null); const treeMenuRef = useRef<HTMLDivElement | null>(null);
@ -467,7 +527,7 @@ export function WebcrtPage() {
}, []); }, []);
const attachSessionResult = useCallback( const attachSessionResult = useCallback(
(target: CliTargetItem, sessionId: string, encoding: string) => { (target: CliTargetItem, sessionId: string, encoding: string, cliHop?: boolean) => {
const key = targetKey(target); const key = targetKey(target);
const existing = tabsRef.current.find((tab) => tab.key === key); const existing = tabsRef.current.find((tab) => tab.key === key);
const pending: TermTab = { const pending: TermTab = {
@ -481,6 +541,7 @@ export function WebcrtPage() {
recording: existing?.recording || false, recording: existing?.recording || false,
encoding, encoding,
errorMessage: undefined, errorMessage: undefined,
cliHop: Boolean(cliHop),
}; };
setTabs((prev) => { setTabs((prev) => {
const without = prev.filter((x) => x.key !== key); const without = prev.filter((x) => x.key !== key);
@ -520,19 +581,19 @@ export function WebcrtPage() {
} }
if (connectingKeysRef.current.has(key)) return; if (connectingKeysRef.current.has(key)) return;
// WebCRT SSH without saved password → credential popup. // Managed / WebCRT SSH without saved password → credential popup (UME uses shared profile).
const isWebcrtSsh = if (isInventorySsh(target) && !target.has_password && !opts?.force) {
target.source === "webcrt" && String(target.protocol || "ssh").toLowerCase() !== "telnet";
if (isWebcrtSsh && !target.has_password && !opts?.force) {
openAuthForTarget(target); openAuthForTarget(target);
return; return;
} }
connectingKeysRef.current.add(key); connectingKeysRef.current.add(key);
if (opts?.force && existing?.sessionId) { // force = brand-new device session; drop any prior PTY first.
const prior = tabsRef.current.find((tab) => tab.key === key);
if (opts?.force && prior?.sessionId) {
try { try {
await closeWebcrtSession(existing.sessionId); await closeWebcrtSession(prior.sessionId);
} catch { } catch {
/* ignore */ /* ignore */
} }
@ -545,11 +606,11 @@ export function WebcrtPage() {
key, key,
sessionId: "", sessionId: "",
wsUrl: "", wsUrl: "",
termEpoch: (existing?.termEpoch || 0) + (opts?.force ? 1 : 0), termEpoch: (prior?.termEpoch || existing?.termEpoch || 0) + (opts?.force ? 1 : 0),
target, target,
status: "connecting", status: "connecting",
connectPhase: "creating", connectPhase: "creating",
recording: existing?.recording || false, recording: prior?.recording || existing?.recording || false,
encoding, encoding,
errorMessage: undefined, errorMessage: undefined,
}; };
@ -588,13 +649,13 @@ export function WebcrtPage() {
status: "connecting", status: "connecting",
connectPhase: "authenticating", connectPhase: "authenticating",
termEpoch: pending.termEpoch + 1, termEpoch: pending.termEpoch + 1,
cliHop: Boolean(sess.cli_hop),
}); });
showOk(t("webcrt.opened", { name: deviceLabel(target) })); showOk(t("webcrt.opened", { name: deviceLabel(target) }));
} catch (err) { } catch (err) {
const message = webcrtErrorMessage(err, t); const message = webcrtErrorMessage(err, t);
const needAuth = const needAuth =
target.source === "webcrt" && isInventorySsh(target) &&
String(target.protocol || "ssh").toLowerCase() !== "telnet" &&
(String(err).includes("credentials_incomplete") || (String(err).includes("credentials_incomplete") ||
String(err).includes("connect_failed") || String(err).includes("connect_failed") ||
isSshAuthFailure(err)); isSshAuthFailure(err));
@ -611,6 +672,23 @@ export function WebcrtPage() {
[openAuthForTarget, showOk, showError, t, updateTab], [openAuthForTarget, showOk, showError, t, updateTab],
); );
/** Re-open WS to an existing backend session (within detach grace). */
const reattachTab = useCallback(
(tab: TermTab) => {
if (!tab.sessionId) return false;
updateTab(tab.key, {
status: "connecting",
connectPhase: "authenticating",
termEpoch: tab.termEpoch + 1,
wsUrl: webcrtWsUrl(tab.sessionId),
errorMessage: undefined,
});
setActiveTabKey(tab.key);
return true;
},
[updateTab],
);
const openTargetRef = useRef(openTarget); const openTargetRef = useRef(openTarget);
openTargetRef.current = openTarget; openTargetRef.current = openTarget;
@ -667,7 +745,7 @@ export function WebcrtPage() {
connect_status: result.ne.connect_status || "unknown", connect_status: result.ne.connect_status || "unknown",
cli_profile_ready: true, cli_profile_ready: true,
}; };
attachSessionResult(target, result.session_id, dims.encoding); attachSessionResult(target, result.session_id, dims.encoding, Boolean(result.cli_hop));
setHostDialogOpen(false); setHostDialogOpen(false);
setHostForm(emptyHostForm()); setHostForm(emptyHostForm());
setSource(listSource === "managed" ? "managed" : "webcrt"); setSource(listSource === "managed" ? "managed" : "webcrt");
@ -703,7 +781,8 @@ export function WebcrtPage() {
try { try {
if (authDialog.mode === "retry" && authDialog.target) { if (authDialog.mode === "retry" && authDialog.target) {
const existing = authDialog.target; const existing = authDialog.target;
if (authForm.savePassword && existing.source === "webcrt") { // Persist to ManagedNE for inventory + Quick Connect; UME never uses this dialog.
if (authForm.savePassword && existing.source !== "ume") {
try { try {
await updateManagedNe(existing.id, { username, password: authForm.password }); await updateManagedNe(existing.id, { username, password: authForm.password });
} catch { } catch {
@ -728,6 +807,7 @@ export function WebcrtPage() {
}, },
sess.session_id, sess.session_id,
dims.encoding, dims.encoding,
Boolean(sess.cli_hop),
); );
setAuthDialog(null); setAuthDialog(null);
setAuthForm(emptyAuthForm()); setAuthForm(emptyAuthForm());
@ -762,7 +842,7 @@ export function WebcrtPage() {
connect_status: result.ne.connect_status || "unknown", connect_status: result.ne.connect_status || "unknown",
cli_profile_ready: true, cli_profile_ready: true,
}; };
attachSessionResult(target, result.session_id, dims.encoding); attachSessionResult(target, result.session_id, dims.encoding, Boolean(result.cli_hop));
setAuthDialog(null); setAuthDialog(null);
setAuthForm(emptyAuthForm()); setAuthForm(emptyAuthForm());
setSource("webcrt"); setSource("webcrt");
@ -881,11 +961,24 @@ export function WebcrtPage() {
[showError, showOk, t], [showError, showOk, t],
); );
const reconnectTab = useCallback(
async (tab: TermTab) => {
// Prefer re-attach while the device PTY may still be in detach grace.
const canReattach =
Boolean(tab.sessionId) &&
!isDeviceClosedMessage(tab.errorMessage) &&
!isSshAuthFailure(tab.errorMessage);
if (canReattach && reattachTab(tab)) return;
await openTarget(tab.target, { force: true });
},
[openTarget, reattachTab],
);
const reconnectActive = useCallback(async () => { const reconnectActive = useCallback(async () => {
const tab = tabsRef.current.find((x) => x.key === activeTabKey); const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return; if (!tab) return;
await openTarget(tab.target, { force: true }); await reconnectTab(tab);
}, [activeTabKey, openTarget]); }, [activeTabKey, reconnectTab]);
const toggleRecording = useCallback(() => { const toggleRecording = useCallback(() => {
const tab = tabsRef.current.find((x) => x.key === activeTabKey); const tab = tabsRef.current.find((x) => x.key === activeTabKey);
@ -893,10 +986,13 @@ export function WebcrtPage() {
const next = !tab.recording; const next = !tab.recording;
if (next) { if (next) {
const seed = termRefs.current.get(tab.key)?.getText() || ""; const seed = termRefs.current.get(tab.key)?.getText() || "";
logBuffersRef.current.set(tab.key, seed ? `${seed}\n` : ""); logBuffersRef.current.set(tab.key, seed ? [`${seed}\n`] : []);
showOk(t("webcrt.actions.recordingOn")); showOk(t("webcrt.actions.recordingOn"));
} else { } else {
const body = logBuffersRef.current.get(tab.key) || termRefs.current.get(tab.key)?.getText() || ""; const chunks = logBuffersRef.current.get(tab.key);
const body = chunks?.length
? chunks.join("")
: termRefs.current.get(tab.key)?.getText() || "";
const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const name = `${deviceLabel(tab.target) || "session"}-${stamp}.log`; const name = `${deviceLabel(tab.target) || "session"}-${stamp}.log`;
downloadText(name, body); downloadText(name, body);
@ -997,6 +1093,12 @@ export function WebcrtPage() {
}, [presetNeId, presetSource, showError, t, setSearchParams]); }, [presetNeId, presetSource, showError, t, setSearchParams]);
const activeTab = tabs.find((x) => x.key === activeTabKey) || null; const activeTab = tabs.find((x) => x.key === activeTabKey) || null;
const sftpBlockedKey = activeTab ? sftpUnavailableReason(activeTab) : "webcrt.err.sftpSsh";
const sftpAllowed = Boolean(activeTab && !sftpBlockedKey);
useEffect(() => {
if (sftpOpen && !sftpAllowed) setSftpOpen(false);
}, [sftpOpen, sftpAllowed]);
const renameWebcrtSession = useCallback( const renameWebcrtSession = useCallback(
async (target: CliTargetItem, nextName: string) => { async (target: CliTargetItem, nextName: string) => {
@ -1271,21 +1373,31 @@ export function WebcrtPage() {
<button <button
type="button" type="button"
className={`webcrt-action-btn${sftpOpen ? " is-active" : ""}`} className={`webcrt-action-btn${sftpOpen ? " is-active" : ""}`}
disabled={!sftpAllowed}
title={sftpAllowed ? t("webcrt.sftp.title") : t(sftpBlockedKey || "webcrt.err.sftpSsh")}
onClick={() => { onClick={() => {
if (!sftpAllowed) {
showError(t(sftpBlockedKey || "webcrt.err.sftpSsh"));
return;
}
setSftpOpen((v) => !v); setSftpOpen((v) => !v);
if (!sftpOpen) void refreshSftp(); if (!sftpOpen) void refreshSftp();
}} }}
> >
SFTP {t("webcrt.sftp.title")}
</button> </button>
</div> </div>
) : null} ) : null}
<div className="webcrt-main__body"> <div className="webcrt-main__body">
{tabs.map((tab) => ( {tabs.map((tab) => {
const isActive = activeTabKey === tab.key;
// Only the active tab mounts xterm + WS; background tabs detach and re-attach on focus.
const mountTerminal = Boolean(isActive && tab.wsUrl);
return (
<div <div
key={`${tab.key}:${tab.termEpoch}`} key={tab.key}
className="webcrt-main__pane" className="webcrt-main__pane"
hidden={activeTabKey !== tab.key} hidden={!isActive}
> >
{tab.status === "connecting" && !tab.wsUrl ? ( {tab.status === "connecting" && !tab.wsUrl ? (
<div className="webcrt-main__placeholder"> <div className="webcrt-main__placeholder">
@ -1297,7 +1409,7 @@ export function WebcrtPage() {
<div className="webcrt-main__placeholder webcrt-main__placeholder--error"> <div className="webcrt-main__placeholder webcrt-main__placeholder--error">
<div>{t("webcrt.status.error")}</div> <div>{t("webcrt.status.error")}</div>
{tab.errorMessage ? <pre className="webcrt-error-detail">{tab.errorMessage}</pre> : null} {tab.errorMessage ? <pre className="webcrt-error-detail">{tab.errorMessage}</pre> : null}
<button type="button" className="webcrt-action-btn is-warn" onClick={() => void openTarget(tab.target, { force: true })}> <button type="button" className="webcrt-action-btn is-warn" onClick={() => void reconnectTab(tab)}>
{t("webcrt.actions.reconnect")} {t("webcrt.actions.reconnect")}
</button> </button>
</div> </div>
@ -1325,13 +1437,15 @@ export function WebcrtPage() {
<button <button
type="button" type="button"
className="webcrt-action-btn is-warn" className="webcrt-action-btn is-warn"
onClick={() => void openTarget(tab.target, { force: true })} onClick={() => void reconnectTab(tab)}
> >
{t("webcrt.actions.reconnect")} {t("webcrt.actions.reconnect")}
</button> </button>
</div> </div>
) : null} ) : null}
{mountTerminal ? (
<WebTerminal <WebTerminal
key={`${tab.key}:${tab.termEpoch}`}
ref={(handle) => { ref={(handle) => {
if (handle) termRefs.current.set(tab.key, handle); if (handle) termRefs.current.set(tab.key, handle);
else termRefs.current.delete(tab.key); else termRefs.current.delete(tab.key);
@ -1348,10 +1462,22 @@ export function WebcrtPage() {
copyOnSelect={sessionOpts.copyOnSelect} copyOnSelect={sessionOpts.copyOnSelect}
pasteDelayMs={sessionOpts.pasteDelayMs} pasteDelayMs={sessionOpts.pasteDelayMs}
keywordHighlight={keywordHl} keywordHighlight={keywordHl}
autoFocus={activeTabKey === tab.key && tab.status === "connected"} autoFocus={tab.status === "connected"}
onStdout={(chunk) => { onStdout={(chunk) => {
const prev = logBuffersRef.current.get(tab.key) || ""; if (!chunk) return;
logBuffersRef.current.set(tab.key, prev + chunk); let parts = logBuffersRef.current.get(tab.key);
if (!parts) {
parts = [];
logBuffersRef.current.set(tab.key, parts);
}
parts.push(chunk);
if (parts.length >= LOG_COMPACT_CHUNKS) {
const joined = parts.join("");
logBuffersRef.current.set(
tab.key,
[joined.length > LOG_MAX_CHARS ? joined.slice(-LOG_MAX_CHARS) : joined],
);
}
}} }}
onReady={() => { onReady={() => {
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40); window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
@ -1363,9 +1489,7 @@ export function WebcrtPage() {
connectPhase: undefined, connectPhase: undefined,
errorMessage: undefined, errorMessage: undefined,
}); });
if (activeTabKey === tab.key) { window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
}
} else if (state === "open" || state === "connecting") { } else if (state === "open" || state === "connecting") {
const nextPhase: ConnectPhase = const nextPhase: ConnectPhase =
phase === "waiting_prompt" || message === "waiting_prompt" phase === "waiting_prompt" || message === "waiting_prompt"
@ -1375,44 +1499,90 @@ export function WebcrtPage() {
status: "connecting", status: "connecting",
connectPhase: nextPhase, connectPhase: nextPhase,
}); });
} else if (state === "warning") {
const m = String(message || "");
const dropMatch = /^queue_dropped:(\d+)/i.exec(m);
if (dropMatch) {
const now = Date.now();
if (now - lastQueueDropToastAtRef.current > 3000) {
lastQueueDropToastAtRef.current = now;
showError(
t("webcrt.err.queueDropped", {
count: Number(dropMatch[1]) || dropMatch[1],
}),
);
}
}
} else if (state === "error") { } else if (state === "error") {
const errMsg = message || t("webcrt.disconnectBannerError"); const errMsg = message || t("webcrt.disconnectBannerError");
const sid = tab.sessionId; const sid = tab.sessionId;
// Re-attach missed the grace window → create a fresh session.
if (sid && isSessionGoneError(errMsg)) {
updateTab(tab.key, {
status: "connecting",
sessionId: "",
wsUrl: "",
connectPhase: "creating",
errorMessage: undefined,
});
void openTarget(tab.target, { force: true });
return;
}
updateTab(tab.key, { updateTab(tab.key, {
status: "error", status: "error",
sessionId: "", sessionId: "",
wsUrl: "", wsUrl: "",
connectPhase: undefined, connectPhase: undefined,
errorMessage: errMsg, errorMessage: webcrtErrorMessage(errMsg, t),
}); });
if (sid) { if (sid) {
void closeWebcrtSession(sid).catch(() => undefined); void closeWebcrtSession(sid).catch(() => undefined);
} }
const tgt = tab.target; if (isInventorySsh(tab.target) && isSshAuthFailure(errMsg)) {
const isWebcrtSsh = openAuthForTarget(tab.target, webcrtErrorMessage(errMsg, t));
tgt.source === "webcrt" &&
String(tgt.protocol || "ssh").toLowerCase() !== "telnet";
if (isWebcrtSsh && isSshAuthFailure(errMsg)) {
openAuthForTarget(tgt, webcrtErrorMessage(errMsg, t));
} }
} else if (state === "closed") { } else if (state === "closed") {
// Ignore local socket teardown noise; real device closes send session closed. const msg = String(message || "");
if (String(message || "").startsWith("websocket_closed:")) return; // Local WS drop: keep session_id so reconnect / tab-focus can re-attach.
if (msg.startsWith("websocket_closed:")) {
updateTab(tab.key, {
status: "closed",
connectPhase: undefined,
errorMessage: t("webcrt.disconnectBanner"),
});
return;
}
const deviceGone = isDeviceClosedMessage(msg);
updateTab(tab.key, { updateTab(tab.key, {
status: "closed", status: "closed",
sessionId: deviceGone ? "" : tab.sessionId,
wsUrl: deviceGone ? "" : tab.wsUrl,
connectPhase: undefined, connectPhase: undefined,
errorMessage: message || t("webcrt.disconnectBanner"), errorMessage: msg || t("webcrt.disconnectBanner"),
}); });
} }
}} }}
/> />
) : null}
</> </>
) : null} ) : null}
</div> </div>
))} );
{sftpOpen && activeTab ? ( })}
{sftpOpen && activeTab && sftpAllowed ? (
<div className="webcrt-sftp"> <div className="webcrt-sftp">
<div className="webcrt-sftp__bar"> <div className="webcrt-sftp__bar">
<button
type="button"
title={t("webcrt.sftp.up")}
onClick={() => {
const parent = sftpParentPath(sftpPath);
setSftpPath(parent);
window.setTimeout(() => void refreshSftp(), 0);
}}
>
{t("webcrt.sftp.up")}
</button>
<input value={sftpPath} onChange={(e) => setSftpPath(e.target.value)} /> <input value={sftpPath} onChange={(e) => setSftpPath(e.target.value)} />
<button type="button" onClick={() => void refreshSftp()}> <button type="button" onClick={() => void refreshSftp()}>
{t("webcrt.sftp.refresh")} {t("webcrt.sftp.refresh")}
@ -1485,7 +1655,8 @@ export function WebcrtPage() {
})(); })();
}} }}
> >
{it.is_dir ? "📁" : "📄"} {it.name} {it.is_dir ? `[${t("webcrt.sftp.dir")}] ` : ""}
{it.name}
{!it.is_dir ? ` (${it.size})` : ""} {!it.is_dir ? ` (${it.size})` : ""}
</button> </button>
</li> </li>

View file

@ -260,6 +260,7 @@ export type CliTargetItem = {
protocol?: string; protocol?: string;
username?: string; username?: string;
has_password?: boolean; has_password?: boolean;
hop_enabled?: boolean;
connect_status: string; connect_status: string;
cli_profile_ready?: boolean; cli_profile_ready?: boolean;
}; };