mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Improve WebCRT UX: real login transcript, session tools, and first-input sync.
Capture Netmiko session_log for login replay, open NE terminals in a fresh tab, tidy the device list UI with reconnect/log/clear/copy, and paint a live prompt after attach so the first keystrokes behave normally. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
30f2db9708
commit
89ff0a7212
11 changed files with 593 additions and 183 deletions
|
|
@ -252,6 +252,7 @@ def _netmiko_over_ssh_client(
|
|||
password: str,
|
||||
enable_secret: str,
|
||||
session_timeout: int | None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
"""Netmiko session over an already-authenticated SSH client (bastion protocol proxy)."""
|
||||
base_cls = _netmiko_driver_class(device_type)
|
||||
|
|
@ -278,6 +279,7 @@ def _netmiko_over_ssh_client(
|
|||
password=password,
|
||||
enable_secret=enable_secret,
|
||||
session_timeout=session_timeout,
|
||||
session_log=session_log,
|
||||
)
|
||||
return _PreauthSession(**dev)
|
||||
|
||||
|
|
@ -291,6 +293,7 @@ def _base_connect_kwargs(
|
|||
password: str,
|
||||
enable_secret: str,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
timeout = int(settings.ne_connect_timeout_sec or 30)
|
||||
dev: dict[str, Any] = {
|
||||
|
|
@ -308,10 +311,17 @@ def _base_connect_kwargs(
|
|||
secret = str(enable_secret or "").strip()
|
||||
if secret:
|
||||
dev["secret"] = secret
|
||||
if session_log is not None:
|
||||
dev["session_log"] = session_log
|
||||
return dev
|
||||
|
||||
|
||||
def _connect_direct(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||
def _connect_direct(
|
||||
creds: dict[str, Any],
|
||||
*,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
|
||||
dev = _base_connect_kwargs(
|
||||
device_type=device_type,
|
||||
|
|
@ -321,6 +331,7 @@ def _connect_direct(creds: dict[str, Any], *, session_timeout: int | None = None
|
|||
password=str(creds["password"]),
|
||||
enable_secret=str(creds.get("enable_secret") or ""),
|
||||
session_timeout=session_timeout,
|
||||
session_log=session_log,
|
||||
)
|
||||
return ConnectHandler(**dev)
|
||||
|
||||
|
|
@ -391,7 +402,12 @@ def _hop_netmiko_device_type(vendor: str, hop_protocol: str) -> str:
|
|||
return normalize_netmiko_device_type(base, hop_protocol)
|
||||
|
||||
|
||||
def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||
def _connect_via_cli_hop(
|
||||
creds: dict[str, Any],
|
||||
*,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
"""Login to ZTE/Huawei/Cisco hop NE, run CLI jump command, then target secondary auth."""
|
||||
hop_host = str(creds.get("hop_host") or "").strip()
|
||||
hop_user = str(creds.get("hop_username") or "").strip()
|
||||
|
|
@ -409,6 +425,7 @@ def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None =
|
|||
password=hop_pass,
|
||||
enable_secret="",
|
||||
session_timeout=session_timeout or 180,
|
||||
session_log=session_log,
|
||||
)
|
||||
conn = ConnectHandler(**hop_dev)
|
||||
try:
|
||||
|
|
@ -425,7 +442,12 @@ def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None =
|
|||
raise
|
||||
|
||||
|
||||
def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||
def _connect_via_bastion(
|
||||
creds: dict[str, Any],
|
||||
*,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
"""SSH to bastion with composite username; bastion proxies to target (protocol proxy)."""
|
||||
hop_host = str(creds.get("hop_host") or "").strip()
|
||||
hop_user = str(creds.get("hop_username") or "").strip()
|
||||
|
|
@ -456,6 +478,7 @@ def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None =
|
|||
password=hop_pass,
|
||||
enable_secret=str(creds.get("enable_secret") or ""),
|
||||
session_timeout=session_timeout or 180,
|
||||
session_log=session_log,
|
||||
)
|
||||
except Exception:
|
||||
if ssh_client is not None:
|
||||
|
|
@ -481,7 +504,12 @@ def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None =
|
|||
return conn
|
||||
|
||||
|
||||
def _connect_via_linux_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||
def _connect_via_linux_hop(
|
||||
creds: dict[str, Any],
|
||||
*,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
"""SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style)."""
|
||||
hop_host = str(creds.get("hop_host") or "").strip()
|
||||
hop_user = str(creds.get("hop_username") or "").strip()
|
||||
|
|
@ -533,6 +561,7 @@ def _connect_via_linux_hop(creds: dict[str, Any], *, session_timeout: int | None
|
|||
password=str(creds["password"]),
|
||||
enable_secret=str(creds.get("enable_secret") or ""),
|
||||
session_timeout=session_timeout,
|
||||
session_log=session_log,
|
||||
)
|
||||
dev["sock"] = channel
|
||||
conn = ConnectHandler(**dev)
|
||||
|
|
@ -556,13 +585,18 @@ def close_netmiko_connection(conn: ConnectHandler | None) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def open_netmiko_connection(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||
def open_netmiko_connection(
|
||||
creds: dict[str, Any],
|
||||
*,
|
||||
session_timeout: int | None = None,
|
||||
session_log: Any = None,
|
||||
) -> ConnectHandler:
|
||||
"""Open a Netmiko connection to the target NE (direct or via configured hop)."""
|
||||
if creds.get("hop_enabled"):
|
||||
vendor = _hop_vendor(creds)
|
||||
if vendor == "linux":
|
||||
return _connect_via_linux_hop(creds, session_timeout=session_timeout)
|
||||
return _connect_via_linux_hop(creds, session_timeout=session_timeout, session_log=session_log)
|
||||
if vendor == "bastion":
|
||||
return _connect_via_bastion(creds, session_timeout=session_timeout)
|
||||
return _connect_via_cli_hop(creds, session_timeout=session_timeout)
|
||||
return _connect_direct(creds, session_timeout=session_timeout)
|
||||
return _connect_via_bastion(creds, session_timeout=session_timeout, session_log=session_log)
|
||||
return _connect_via_cli_hop(creds, session_timeout=session_timeout, session_log=session_log)
|
||||
return _connect_direct(creds, session_timeout=session_timeout, session_log=session_log)
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
}
|
||||
)
|
||||
|
||||
# Replay post-login banner/prompt so the UI is not blank until the user presses Enter.
|
||||
# Replay login transcript once, then draw a live prompt so first keystrokes match later lines.
|
||||
bootstrap = bytes(sess.bootstrap_output or b"")
|
||||
if bootstrap:
|
||||
try:
|
||||
|
|
@ -107,12 +107,8 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
)
|
||||
except Exception:
|
||||
_log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True)
|
||||
else:
|
||||
# Last resort: ask the device to redraw the prompt into the live reader.
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(None, sess.write_stdin, "\r")
|
||||
except Exception:
|
||||
pass
|
||||
# Only replay login banner on the first attach (StrictMode remount / blip).
|
||||
sess.bootstrap_output = b""
|
||||
|
||||
stop = asyncio.Event()
|
||||
|
||||
|
|
@ -141,6 +137,12 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
break
|
||||
|
||||
reader_task = asyncio.create_task(pump_stdout())
|
||||
if sess.needs_live_prompt:
|
||||
sess.needs_live_prompt = False
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(None, sess.write_stdin, "\r")
|
||||
except Exception:
|
||||
_log.debug("webcrt live prompt sync failed session=%s", session_id, exc_info=True)
|
||||
try:
|
||||
while not stop.is_set():
|
||||
raw = await websocket.receive_text()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
|
|
@ -77,6 +78,29 @@ def map_network_cli_keys(data: str) -> str:
|
|||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def channel_return(conn: ConnectHandler | None) -> str:
|
||||
"""Netmiko line ending for this session (SSH usually \\n, Telnet often \\r\\n)."""
|
||||
if conn is None:
|
||||
return "\n"
|
||||
ret = getattr(conn, "RETURN", None)
|
||||
if isinstance(ret, str) and ret:
|
||||
return ret
|
||||
return "\n"
|
||||
|
||||
|
||||
def map_network_cli_enter(data: str, conn: ConnectHandler | None) -> str:
|
||||
"""Map xterm Enter (\\r) to the device's Netmiko RETURN."""
|
||||
text = str(data or "")
|
||||
if not text:
|
||||
return text
|
||||
ret = channel_return(conn)
|
||||
if ret == "\r":
|
||||
return text
|
||||
# Prefer replacing CRLF first so Telnet RETURN \\r\\n does not double-expand.
|
||||
return text.replace("\r\n", ret).replace("\r", ret)
|
||||
|
||||
|
||||
def _drain_channel(conn: ConnectHandler, *, rounds: int = 10, wait: float = 0.12) -> str:
|
||||
"""Read whatever is already sitting on the channel after login."""
|
||||
chunks: list[str] = []
|
||||
|
|
@ -97,6 +121,19 @@ def _drain_channel(conn: ConnectHandler, *, rounds: int = 10, wait: float = 0.12
|
|||
return "".join(chunks)
|
||||
|
||||
|
||||
def _session_log_text(buf: io.BytesIO | None) -> str:
|
||||
"""Decode Netmiko session_log buffer into display text."""
|
||||
if buf is None:
|
||||
return ""
|
||||
try:
|
||||
raw = buf.getvalue()
|
||||
except Exception:
|
||||
return ""
|
||||
if isinstance(raw, bytes):
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
return str(raw or "")
|
||||
|
||||
|
||||
def _looks_like_cli_prompt(text: str) -> bool:
|
||||
s = str(text or "").rstrip()
|
||||
if not s:
|
||||
|
|
@ -105,6 +142,61 @@ def _looks_like_cli_prompt(text: str) -> bool:
|
|||
return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s))
|
||||
|
||||
|
||||
# Cisco/Netmiko often yields "R2#R2#" when a sync Enter is appended without a newline.
|
||||
_GLUED_PROMPT_RE = re.compile(r"(?<=[#>])(?=(?:[A-Za-z0-9][\w.\-:]{0,62})[#>])")
|
||||
|
||||
|
||||
def normalize_cli_transcript(text: str) -> str:
|
||||
"""Normalize login transcript for xterm (convertEol) and un-glue prompts."""
|
||||
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
s = _GLUED_PROMPT_RE.sub("\n", s)
|
||||
lines = s.split("\n")
|
||||
while lines and not str(lines[-1]).strip():
|
||||
lines.pop()
|
||||
while len(lines) >= 2 and lines[-1] == lines[-2] and _looks_like_cli_prompt(lines[-1]):
|
||||
lines.pop()
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def strip_trailing_prompt_lines(text: str) -> str:
|
||||
"""Remove final prompt line(s) so a live RETURN can paint the interactive prompt."""
|
||||
lines = str(text or "").split("\n")
|
||||
while lines and not str(lines[-1]).strip():
|
||||
lines.pop()
|
||||
while lines and _looks_like_cli_prompt(lines[-1]):
|
||||
lines.pop()
|
||||
while lines and not str(lines[-1]).strip():
|
||||
lines.pop()
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def prepare_bootstrap_output(text: str) -> str:
|
||||
"""Login transcript for UI replay; ends with newline, without the final prompt."""
|
||||
body = strip_trailing_prompt_lines(normalize_cli_transcript(text))
|
||||
if not body:
|
||||
return ""
|
||||
return body if body.endswith("\n") else body + "\n"
|
||||
|
||||
|
||||
def _prime_interactive_channel(conn: ConnectHandler) -> None:
|
||||
"""Send one RETURN after Netmiko login so the interactive channel is fully ready."""
|
||||
try:
|
||||
_drain_channel(conn, rounds=4, wait=0.05)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.write_channel(channel_return(conn))
|
||||
except Exception:
|
||||
try:
|
||||
conn.write_channel("\n")
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
_drain_channel(conn, rounds=8, wait=0.1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
|
@ -151,6 +243,7 @@ class WebcrtSession:
|
|||
closed: bool = False
|
||||
close_reason: str = ""
|
||||
bootstrap_output: bytes = b""
|
||||
needs_live_prompt: bool = True
|
||||
out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue)
|
||||
_reader: threading.Thread | None = field(default=None, repr=False)
|
||||
_write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||
|
|
@ -166,26 +259,26 @@ class WebcrtSession:
|
|||
return
|
||||
if self.cli_keymap:
|
||||
text = map_network_cli_keys(text)
|
||||
text = map_network_cli_enter(text, self.conn)
|
||||
if not text:
|
||||
return
|
||||
with self._write_lock:
|
||||
# Prefer raw channel write so control bytes are not altered.
|
||||
channel = getattr(self.conn, "remote_conn", None)
|
||||
try:
|
||||
if channel is not None and hasattr(channel, "send") and callable(channel.send):
|
||||
payload = text.encode(getattr(self.conn, "encoding", None) or "utf-8", errors="replace")
|
||||
channel.send(payload)
|
||||
elif channel is not None and hasattr(channel, "write") and callable(channel.write):
|
||||
encoding = getattr(self.conn, "encoding", None) or "utf-8"
|
||||
if isinstance(text, str):
|
||||
channel.write(text.encode(encoding, errors="replace"))
|
||||
else:
|
||||
channel.write(text)
|
||||
else:
|
||||
self.conn.write_channel(text)
|
||||
except Exception:
|
||||
# Fallback to netmiko helper.
|
||||
# Network CLIs: use Netmiko write_channel so RETURN/encoding match the driver.
|
||||
if self.cli_keymap:
|
||||
self.conn.write_channel(text)
|
||||
else:
|
||||
channel = getattr(self.conn, "remote_conn", None)
|
||||
try:
|
||||
if channel is not None and hasattr(channel, "send") and callable(channel.send):
|
||||
payload = text.encode(getattr(self.conn, "encoding", None) or "utf-8", errors="replace")
|
||||
channel.send(payload)
|
||||
elif channel is not None and hasattr(channel, "write") and callable(channel.write):
|
||||
encoding = getattr(self.conn, "encoding", None) or "utf-8"
|
||||
channel.write(text.encode(encoding, errors="replace") if isinstance(text, str) else text)
|
||||
else:
|
||||
self.conn.write_channel(text)
|
||||
except Exception:
|
||||
self.conn.write_channel(text)
|
||||
self.touch()
|
||||
def resize(self, cols: int, rows: int) -> None:
|
||||
if self.closed or self.conn is None:
|
||||
|
|
@ -367,9 +460,14 @@ def create_session(
|
|||
vendor = str(device.get("vendor") or creds.get("vendor") or "")
|
||||
cli_keymap = uses_network_cli_keymap(device_type, vendor)
|
||||
|
||||
# Capture real Telnet/SSH login I/O (banner, Username/Password, prompts).
|
||||
log_buf = io.BytesIO()
|
||||
try:
|
||||
conn = open_netmiko_connection(creds, session_timeout=connect_timeout)
|
||||
conn = open_netmiko_connection(
|
||||
creds, session_timeout=connect_timeout, session_log=log_buf
|
||||
)
|
||||
except Exception as exc:
|
||||
partial = _session_log_text(log_buf).strip()
|
||||
_audit(
|
||||
"session_open_failed",
|
||||
session_id=session_id,
|
||||
|
|
@ -378,8 +476,13 @@ def create_session(
|
|||
source=str(device.get("source") or ""),
|
||||
client=client or "",
|
||||
error=str(exc)[:500],
|
||||
transcript_len=len(partial),
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=f"connect_failed:{exc}") from exc
|
||||
detail = f"connect_failed:{exc}"
|
||||
if partial:
|
||||
# Keep detail bounded; UI surfaces this on open failure.
|
||||
detail = f"{detail}\n--- device transcript ---\n{partial[-4000:]}"
|
||||
raise HTTPException(status_code=502, detail=detail) from exc
|
||||
|
||||
channel = getattr(conn, "remote_conn", None)
|
||||
if channel is not None and hasattr(channel, "resize_pty"):
|
||||
|
|
@ -388,20 +491,23 @@ def create_session(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Prefer session_log (full login transcript). Prime channel, then strip the final
|
||||
# prompt so WebSocket attach can paint a live interactive prompt (first keystrokes
|
||||
# then behave like subsequent lines).
|
||||
_prime_interactive_channel(conn)
|
||||
bootstrap = prepare_bootstrap_output(_session_log_text(log_buf))
|
||||
if not bootstrap.strip():
|
||||
try:
|
||||
more = _drain_channel(conn, rounds=6, wait=0.1)
|
||||
except Exception:
|
||||
more = ""
|
||||
if more:
|
||||
bootstrap = prepare_bootstrap_output(more)
|
||||
# Discard any unread bytes so the live reader starts clean.
|
||||
try:
|
||||
leftover = _drain_channel(conn, rounds=8, wait=0.1)
|
||||
_drain_channel(conn, rounds=3, wait=0.05)
|
||||
except Exception:
|
||||
leftover = ""
|
||||
# Netmiko often consumes the login banner; nudge Enter once to surface the prompt.
|
||||
if not _looks_like_cli_prompt(leftover):
|
||||
try:
|
||||
conn.write_channel("\r")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
leftover = (leftover or "") + _drain_channel(conn, rounds=8, wait=0.12)
|
||||
except Exception:
|
||||
pass
|
||||
pass
|
||||
|
||||
sess = WebcrtSession(
|
||||
session_id=session_id,
|
||||
|
|
@ -415,7 +521,8 @@ def create_session(
|
|||
vendor=vendor,
|
||||
cli_keymap=cli_keymap,
|
||||
conn=conn,
|
||||
bootstrap_output=str(leftover or "").encode("utf-8", errors="replace"),
|
||||
bootstrap_output=str(bootstrap or "").encode("utf-8", errors="replace"),
|
||||
needs_live_prompt=True,
|
||||
)
|
||||
# Keep bootstrap for WS attach replay; do not rely solely on out_queue (StrictMode remount).
|
||||
sess.start_reader()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from netx_api import webcrt_service as svc
|
|||
class _FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.written: list[str] = []
|
||||
self.RETURN = "\n"
|
||||
self.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
|
||||
self.remote_conn.recv_ready.return_value = False
|
||||
self.remote_conn.exit_status_ready.return_value = False
|
||||
|
|
@ -54,6 +55,9 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
)
|
||||
sess.write_stdin("show ver\n")
|
||||
self.assertEqual(conn.written, ["show ver\n"])
|
||||
# xterm Enter (\r) -> Netmiko RETURN (\n for SSH)
|
||||
sess.write_stdin("\r")
|
||||
self.assertEqual(conn.written[-1], "\n")
|
||||
# xterm DEL / arrows -> network CLI controls
|
||||
sess.write_stdin("\x7f")
|
||||
self.assertEqual(conn.written[-1], "\x08")
|
||||
|
|
@ -69,6 +73,15 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
self.assertEqual(svc.map_network_cli_keys("\x1b[D"), "\x02")
|
||||
self.assertTrue(svc.uses_network_cli_keymap("huawei", "Huawei"))
|
||||
self.assertFalse(svc.uses_network_cli_keymap("linux", "bastion"))
|
||||
self.assertEqual(svc.map_network_cli_enter("\r", _FakeConn()), "\n") # type: ignore[arg-type]
|
||||
telnet = _FakeConn()
|
||||
telnet.RETURN = "\r\n"
|
||||
self.assertEqual(svc.map_network_cli_enter("\r", telnet), "\r\n") # type: ignore[arg-type]
|
||||
self.assertEqual(svc.normalize_cli_transcript("R2#R2#\nR2#"), "R2#")
|
||||
self.assertEqual(svc.normalize_cli_transcript("banner\nR2#R2#"), "banner\nR2#")
|
||||
self.assertEqual(svc.prepare_bootstrap_output("login\nR2#\nR2#"), "login\n")
|
||||
self.assertTrue(svc.prepare_bootstrap_output("login\nR2#").endswith("\n"))
|
||||
self.assertFalse(svc._looks_like_cli_prompt(svc.prepare_bootstrap_output("login\nR2#").rstrip("\n") or "x"))
|
||||
|
||||
@patch.object(svc, "_audit")
|
||||
@patch.object(svc, "open_netmiko_connection")
|
||||
|
|
@ -133,7 +146,18 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
},
|
||||
)
|
||||
fake = _FakeConn()
|
||||
mock_open.return_value = fake
|
||||
|
||||
def _open_with_log(*_a, **kwargs):
|
||||
log_buf = kwargs.get("session_log")
|
||||
if log_buf is not None and hasattr(log_buf, "write"):
|
||||
log_buf.write(
|
||||
b"Warning: Telnet is not a secure protocol...\r\n"
|
||||
b"Username:huawei\r\nPassword:\r\n"
|
||||
b"<r1>"
|
||||
)
|
||||
return fake
|
||||
|
||||
mock_open.side_effect = _open_with_log
|
||||
|
||||
db = MagicMock()
|
||||
out = svc.create_session(db, ne_id="ne-hop", cols=100, rows=30, client="test")
|
||||
|
|
@ -141,15 +165,19 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
called_creds = mock_open.call_args.args[0]
|
||||
self.assertTrue(called_creds["hop_enabled"])
|
||||
self.assertEqual(called_creds["hop_vendor"], "bastion")
|
||||
self.assertIn("session_log", mock_open.call_args.kwargs)
|
||||
fake.remote_conn.resize_pty.assert_called()
|
||||
self.assertEqual(out["ne_id"], "ne-hop")
|
||||
sess = svc.get_session(out["session_id"])
|
||||
assert sess is not None
|
||||
# create_session may nudge Enter when no prompt was drained.
|
||||
boot = sess.bootstrap_output.decode("utf-8", errors="replace")
|
||||
self.assertIn("Username:huawei", boot)
|
||||
# Final prompt is stripped; live RETURN on WS attach paints the interactive prompt.
|
||||
self.assertNotIn("<r1>", boot)
|
||||
self.assertTrue(sess.needs_live_prompt)
|
||||
before = list(fake.written)
|
||||
sess.write_stdin("\n")
|
||||
self.assertEqual(fake.written[len(before) :], ["\n"])
|
||||
self.assertTrue(isinstance(sess.bootstrap_output, (bytes, bytearray)))
|
||||
svc.close_session(out["session_id"], reason="test")
|
||||
|
||||
@patch.object(svc, "_audit")
|
||||
|
|
|
|||
|
|
@ -1,27 +1,79 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
export type WebTerminalHandle = {
|
||||
clear: () => void;
|
||||
copyAll: () => Promise<string>;
|
||||
getText: () => string;
|
||||
fit: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
wsUrl: string;
|
||||
title?: string;
|
||||
recording?: boolean;
|
||||
onStatus?: (state: string, message?: string) => void;
|
||||
onReady?: () => void;
|
||||
onStdout?: (data: string) => void;
|
||||
};
|
||||
|
||||
export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
|
||||
function serializeTerminal(term: Terminal): string {
|
||||
const buf = term.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < buf.length; i += 1) {
|
||||
const line = buf.getLine(i);
|
||||
if (!line) continue;
|
||||
lines.push(line.translateToString(true));
|
||||
}
|
||||
return lines.join("\n").replace(/\s+$/g, "");
|
||||
}
|
||||
|
||||
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
|
||||
{ wsUrl, title, recording, 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 onStatusRef = useRef(onStatus);
|
||||
const onReadyRef = useRef(onReady);
|
||||
const onStdoutRef = useRef(onStdout);
|
||||
const recordingRef = useRef(!!recording);
|
||||
|
||||
useEffect(() => {
|
||||
onStatusRef.current = onStatus;
|
||||
onReadyRef.current = onReady;
|
||||
}, [onStatus, onReady]);
|
||||
onStdoutRef.current = onStdout;
|
||||
}, [onStatus, onReady, onStdout]);
|
||||
|
||||
useEffect(() => {
|
||||
recordingRef.current = !!recording;
|
||||
}, [recording]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clear: () => {
|
||||
termRef.current?.clear();
|
||||
termRef.current?.write("\x1b[H\x1b[2J");
|
||||
},
|
||||
copyAll: async () => {
|
||||
const text = termRef.current ? serializeTerminal(termRef.current) : "";
|
||||
if (text && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
return text;
|
||||
},
|
||||
getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
|
||||
fit: () => {
|
||||
try {
|
||||
fitRef.current?.fit();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
|
|
@ -32,9 +84,9 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
|
|||
fontSize: 13,
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
theme: {
|
||||
background: "#0f172a",
|
||||
background: "#0b1220",
|
||||
foreground: "#e2e8f0",
|
||||
cursor: "#93c5fd",
|
||||
cursor: "#e2e8f0",
|
||||
},
|
||||
convertEol: true,
|
||||
});
|
||||
|
|
@ -51,7 +103,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
|
|||
/* ignore */
|
||||
}
|
||||
};
|
||||
// Fit after layout; hidden/zero-size parents need a deferred pass.
|
||||
requestAnimationFrame(() => {
|
||||
doFit();
|
||||
window.setTimeout(doFit, 50);
|
||||
|
|
@ -60,7 +111,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
|
|||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
onStatusRef.current?.("connecting");
|
||||
term.writeln(`\x1b[90mConnecting${title ? ` ${title}` : ""}…\x1b[0m`);
|
||||
|
||||
const sendJson = (payload: Record<string, unknown>) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
|
|
@ -86,21 +136,15 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
|
|||
data?: string;
|
||||
state?: string;
|
||||
message?: string;
|
||||
ne_name?: string;
|
||||
ne_ip?: string;
|
||||
protocol?: string;
|
||||
};
|
||||
if (msg.type === "stdout" && typeof msg.data === "string") {
|
||||
term.write(msg.data);
|
||||
if (recordingRef.current) onStdoutRef.current?.(msg.data);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "status") {
|
||||
onStatusRef.current?.(String(msg.state || ""), msg.message);
|
||||
if (msg.state === "connected") {
|
||||
const where = [msg.ne_name || title, msg.ne_ip, msg.protocol].filter(Boolean).join(" · ");
|
||||
term.writeln(`\x1b[90m--- session ready${where ? `: ${where}` : ""} ---\x1b[0m`);
|
||||
return;
|
||||
}
|
||||
if (msg.state === "connected") return;
|
||||
if (msg.state === "closed" || msg.state === "error") {
|
||||
const detail = msg.message ? `: ${msg.message}` : "";
|
||||
term.writeln(`\r\n\x1b[33m[session ${msg.state}${detail}]\x1b[0m`);
|
||||
|
|
@ -109,14 +153,15 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
|
|||
}
|
||||
if (msg.type === "pong") return;
|
||||
} catch {
|
||||
term.write(String(ev.data || ""));
|
||||
const raw = String(ev.data || "");
|
||||
term.write(raw);
|
||||
if (recordingRef.current) onStdoutRef.current?.(raw);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
onStatusRef.current?.("error", "websocket_error");
|
||||
term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m");
|
||||
term.writeln("\x1b[33mHint: ensure API has package 'websockets' and is restarted; Vite UI should reach ws://127.0.0.1:8890\x1b[0m");
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
|
|
@ -150,7 +195,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
|
|||
window.removeEventListener("resize", onWinResize);
|
||||
ro?.disconnect();
|
||||
dataDisposable.dispose();
|
||||
// Do not send {type:"close"} here — React StrictMode remounts and needs reconnect.
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
|
|
@ -164,4 +208,4 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
|
|||
}, [wsUrl, title]);
|
||||
|
||||
return <div className="webcrt-term" ref={hostRef} />;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -541,10 +541,11 @@ const en = {
|
|||
},
|
||||
webcrt: {
|
||||
title: "WebCRT",
|
||||
sessionManager: "Sessions",
|
||||
deviceList: "Device list",
|
||||
sessionManager: "Device list",
|
||||
connect: "Connect",
|
||||
hint: "Pick a connected NE and open an interactive terminal in the browser. Credentials stay on the server.",
|
||||
hintCrt: "Select an NE on the left, then double-click or press ⚡. Includes managed and UME inventory; search + paging for large fleets.",
|
||||
hint: "Pick an NE and open an interactive terminal in the browser. Credentials stay on the server.",
|
||||
hintCrt: "Click an NE on the left to open a terminal. Includes managed and UME inventory; search + paging for large fleets.",
|
||||
filterKeyword: "Filter",
|
||||
filterKeywordPh: "Filter by name / IP / tag",
|
||||
empty: "No matching NEs. Switch source or refine the search.",
|
||||
|
|
@ -567,6 +568,18 @@ const en = {
|
|||
error: "Error",
|
||||
closed: "Closed",
|
||||
},
|
||||
actions: {
|
||||
reconnect: "Reconnect",
|
||||
recordLog: "Record log",
|
||||
clear: "Clear",
|
||||
copy: "Copy",
|
||||
recording: "Recording",
|
||||
recordingOn: "Log recording started",
|
||||
recordingSaved: "Log saved",
|
||||
copied: "Copied to clipboard",
|
||||
copyEmpty: "Terminal is empty",
|
||||
copyFailed: "Copy failed",
|
||||
},
|
||||
err: {
|
||||
sessionLimit: "Session limit reached; close other terminals first",
|
||||
neNotFound: "Network element not found",
|
||||
|
|
|
|||
|
|
@ -539,10 +539,11 @@ const zh = {
|
|||
},
|
||||
webcrt: {
|
||||
title: "WebCRT",
|
||||
sessionManager: "会话管理",
|
||||
deviceList: "设备列表",
|
||||
sessionManager: "设备列表",
|
||||
connect: "连接",
|
||||
hint: "选择已连通网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。",
|
||||
hintCrt: "在左侧选择网元后双击或点 ⚡ 连接;支持托管网元与 UME 网元,可用搜索与分页浏览上万台设备。",
|
||||
hint: "选择网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。",
|
||||
hintCrt: "在左侧点击网元即可打开终端;支持托管与 UME 网元,可用搜索与分页浏览。",
|
||||
filterKeyword: "筛选",
|
||||
filterKeywordPh: "按名称 / IP / 标签筛选",
|
||||
empty: "没有匹配的网元。可切换来源或调整搜索关键字。",
|
||||
|
|
@ -565,6 +566,18 @@ const zh = {
|
|||
error: "错误",
|
||||
closed: "已关闭",
|
||||
},
|
||||
actions: {
|
||||
reconnect: "重新连接",
|
||||
recordLog: "记录日志",
|
||||
clear: "清空",
|
||||
copy: "拷贝",
|
||||
recording: "记录中",
|
||||
recordingOn: "已开始记录日志",
|
||||
recordingSaved: "日志已保存",
|
||||
copied: "已复制到剪贴板",
|
||||
copyEmpty: "终端内容为空",
|
||||
copyFailed: "复制失败",
|
||||
},
|
||||
err: {
|
||||
sessionLimit: "并发会话已达上限,请先关闭其他终端",
|
||||
neNotFound: "网元不存在",
|
||||
|
|
|
|||
|
|
@ -1456,23 +1456,31 @@ pre {
|
|||
background: #f0f0f0;
|
||||
border-right: 1px solid #808080;
|
||||
min-width: 0;
|
||||
min-height: 0; /* grid/flex: allow tree to shrink and scroll */
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 8px 6px 10px;
|
||||
background: linear-gradient(#f8f8f8, #e4e4e4);
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__title {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
background: linear-gradient(#f8f8f8, #e4e4e4);
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid #d0d0d0;
|
||||
background: #ececec;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.webcrt-icon-btn {
|
||||
|
|
@ -1494,6 +1502,7 @@ pre {
|
|||
.webcrt-sidebar__search {
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #d0d0d0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__search input {
|
||||
|
|
@ -1511,6 +1520,7 @@ pre {
|
|||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0;
|
||||
border-bottom: 1px solid #c8c8c8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-source-tabs__btn {
|
||||
|
|
@ -1532,10 +1542,12 @@ pre {
|
|||
}
|
||||
|
||||
.webcrt-tree {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
min-height: 0;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.webcrt-tree__folder {
|
||||
|
|
@ -1612,6 +1624,7 @@ pre {
|
|||
background: #ececec;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__pager-btns {
|
||||
|
|
@ -1636,6 +1649,7 @@ pre {
|
|||
display: flex;
|
||||
border-top: 1px solid #a8a8a8;
|
||||
background: #ddd;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__footer button {
|
||||
|
|
@ -1698,6 +1712,40 @@ pre {
|
|||
color: #475569;
|
||||
}
|
||||
|
||||
.webcrt-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #f3f4f6;
|
||||
border-bottom: 1px solid #d1d5db;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #c0c0c0;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: #1e293b;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webcrt-action-btn:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.webcrt-action-btn.is-active {
|
||||
background: #dbeafe;
|
||||
border-color: #93c5fd;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.webcrt-main__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
|
@ -1752,12 +1800,7 @@ pre {
|
|||
}
|
||||
|
||||
.webcrt-statusline {
|
||||
padding: 3px 8px;
|
||||
background: #ececec;
|
||||
border-top: 1px solid #a8a8a8;
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
flex-shrink: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { useToast } from "../hooks/useToast";
|
|||
import type { ManagedNeItem } from "../types";
|
||||
import { pageCount } from "../utils/display";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||
import { openNewModuleWindow } from "../utils/moduleWindows";
|
||||
import {
|
||||
defaultHopTemplate,
|
||||
isAutoHopTemplate,
|
||||
|
|
@ -777,7 +777,7 @@ export function NePage() {
|
|||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() =>
|
||||
openOrFocusModule({
|
||||
openNewModuleWindow({
|
||||
moduleId: "webcrt",
|
||||
path: `/webcrt?ne_id=${encodeURIComponent(row.id)}`,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { WebTerminal } from "../components/WebTerminal";
|
||||
import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import {
|
||||
|
|
@ -20,15 +20,21 @@ type TermTab = {
|
|||
key: string;
|
||||
sessionId: string;
|
||||
wsUrl: string;
|
||||
termEpoch: number;
|
||||
target: CliTargetItem;
|
||||
status: "connecting" | "connected" | "error" | "closed";
|
||||
errorMessage?: string;
|
||||
recording: boolean;
|
||||
};
|
||||
|
||||
function targetKey(t: Pick<CliTargetItem, "source" | "id">): string {
|
||||
return `${t.source}:${t.id}`;
|
||||
}
|
||||
|
||||
function deviceLabel(t: Pick<CliTargetItem, "name" | "ip_address">): string {
|
||||
return String(t.name || t.ip_address || "").trim() || "-";
|
||||
}
|
||||
|
||||
function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string, string | number>) => string): string {
|
||||
const raw = String(err);
|
||||
if (raw.includes("webcrt_session_limit")) return t("webcrt.err.sessionLimit");
|
||||
|
|
@ -39,6 +45,16 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string,
|
|||
return raw;
|
||||
}
|
||||
|
||||
function downloadText(filename: string, text: string) {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function WebcrtPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
|
|
@ -50,11 +66,13 @@ export function WebcrtPage() {
|
|||
const [keywordInput, setKeywordInput] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedKey, setSelectedKey] = useState("");
|
||||
const [tabs, setTabs] = useState<TermTab[]>([]);
|
||||
const [activeTabKey, setActiveTabKey] = useState("");
|
||||
const connectingKeysRef = useRef<Set<string>>(new Set());
|
||||
const presetDoneRef = useRef(false);
|
||||
const tabsRef = useRef<TermTab[]>([]);
|
||||
tabsRef.current = tabs;
|
||||
const termRefs = useRef<Map<string, WebTerminalHandle>>(new Map());
|
||||
const logBuffersRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
|
|
@ -74,34 +92,39 @@ export function WebcrtPage() {
|
|||
const total = targetsQuery.data?.total ?? 0;
|
||||
const pages = pageCount(total, PAGE_SIZE);
|
||||
|
||||
const selected = useMemo(
|
||||
() => items.find((x) => targetKey(x) === selectedKey) || null,
|
||||
[items, selectedKey],
|
||||
);
|
||||
|
||||
const updateTab = useCallback((key: string, patch: Partial<TermTab>) => {
|
||||
setTabs((prev) => prev.map((tab) => (tab.key === key ? { ...tab, ...patch } : tab)));
|
||||
}, []);
|
||||
|
||||
const openTarget = useCallback(
|
||||
async (target: CliTargetItem) => {
|
||||
async (target: CliTargetItem, opts?: { force?: boolean }) => {
|
||||
const key = targetKey(target);
|
||||
const existing = tabs.find((tab) => tab.key === key && tab.status !== "closed" && tab.status !== "error");
|
||||
if (existing) {
|
||||
const existing = tabsRef.current.find(
|
||||
(tab) => tab.key === key && tab.status !== "closed" && tab.status !== "error",
|
||||
);
|
||||
if (existing && !opts?.force) {
|
||||
setActiveTabKey(existing.key);
|
||||
setSelectedKey(key);
|
||||
return;
|
||||
}
|
||||
if (connectingKeysRef.current.has(key)) return;
|
||||
connectingKeysRef.current.add(key);
|
||||
setSelectedKey(key);
|
||||
|
||||
if (opts?.force && existing?.sessionId) {
|
||||
try {
|
||||
await closeWebcrtSession(existing.sessionId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const pending: TermTab = {
|
||||
key,
|
||||
sessionId: "",
|
||||
wsUrl: "",
|
||||
termEpoch: (existing?.termEpoch || 0) + (opts?.force ? 1 : 0),
|
||||
target,
|
||||
status: "connecting",
|
||||
recording: existing?.recording || false,
|
||||
};
|
||||
setTabs((prev) => {
|
||||
const without = prev.filter((x) => x.key !== key);
|
||||
|
|
@ -118,8 +141,13 @@ export function WebcrtPage() {
|
|||
: { ne_id: target.id, cols, rows };
|
||||
const sess = await createWebcrtSession(body);
|
||||
const wsUrl = webcrtWsUrl(sess.session_id);
|
||||
updateTab(key, { sessionId: sess.session_id, wsUrl, status: "connecting" });
|
||||
showOk(t("webcrt.opened", { name: target.name || target.ip_address }));
|
||||
updateTab(key, {
|
||||
sessionId: sess.session_id,
|
||||
wsUrl,
|
||||
status: "connecting",
|
||||
termEpoch: pending.termEpoch + 1,
|
||||
});
|
||||
showOk(t("webcrt.opened", { name: deviceLabel(target) }));
|
||||
} catch (err) {
|
||||
const message = webcrtErrorMessage(err, t);
|
||||
updateTab(key, { status: "error", errorMessage: message });
|
||||
|
|
@ -128,12 +156,15 @@ export function WebcrtPage() {
|
|||
connectingKeysRef.current.delete(key);
|
||||
}
|
||||
},
|
||||
[tabs, showOk, showError, t, updateTab],
|
||||
[showOk, showError, t, updateTab],
|
||||
);
|
||||
|
||||
const openTargetRef = useRef(openTarget);
|
||||
openTargetRef.current = openTarget;
|
||||
|
||||
const closeTab = useCallback(
|
||||
async (key: string) => {
|
||||
const tab = tabs.find((x) => x.key === key);
|
||||
const tab = tabsRef.current.find((x) => x.key === key);
|
||||
if (tab?.sessionId) {
|
||||
try {
|
||||
await closeWebcrtSession(tab.sessionId);
|
||||
|
|
@ -141,6 +172,8 @@ export function WebcrtPage() {
|
|||
/* ignore */
|
||||
}
|
||||
}
|
||||
logBuffersRef.current.delete(key);
|
||||
termRefs.current.delete(key);
|
||||
setTabs((prev) => {
|
||||
const next = prev.filter((x) => x.key !== key);
|
||||
if (activeTabKey === key) {
|
||||
|
|
@ -149,73 +182,113 @@ export function WebcrtPage() {
|
|||
return next;
|
||||
});
|
||||
},
|
||||
[tabs, activeTabKey],
|
||||
[activeTabKey],
|
||||
);
|
||||
|
||||
// One-shot open from /webcrt?ne_id=...
|
||||
const reconnectActive = useCallback(async () => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab) return;
|
||||
await openTarget(tab.target, { force: true });
|
||||
}, [activeTabKey, openTarget]);
|
||||
|
||||
const toggleRecording = useCallback(() => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab) return;
|
||||
const next = !tab.recording;
|
||||
if (next) {
|
||||
const seed = termRefs.current.get(tab.key)?.getText() || "";
|
||||
logBuffersRef.current.set(tab.key, seed ? `${seed}\n` : "");
|
||||
showOk(t("webcrt.actions.recordingOn"));
|
||||
} else {
|
||||
const body = logBuffersRef.current.get(tab.key) || termRefs.current.get(tab.key)?.getText() || "";
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const name = `${deviceLabel(tab.target) || "session"}-${stamp}.log`;
|
||||
downloadText(name, body);
|
||||
logBuffersRef.current.delete(tab.key);
|
||||
showOk(t("webcrt.actions.recordingSaved"));
|
||||
}
|
||||
updateTab(tab.key, { recording: next });
|
||||
}, [activeTabKey, showOk, t, updateTab]);
|
||||
|
||||
const clearActive = useCallback(() => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab) return;
|
||||
termRefs.current.get(tab.key)?.clear();
|
||||
}, [activeTabKey]);
|
||||
|
||||
const copyActive = useCallback(async () => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab) return;
|
||||
try {
|
||||
const text = await termRefs.current.get(tab.key)?.copyAll();
|
||||
if (text) showOk(t("webcrt.actions.copied"));
|
||||
else showError(t("webcrt.actions.copyEmpty"));
|
||||
} catch {
|
||||
showError(t("webcrt.actions.copyFailed"));
|
||||
}
|
||||
}, [activeTabKey, showOk, showError, t]);
|
||||
|
||||
// Auto-connect from /webcrt?ne_id=...
|
||||
useEffect(() => {
|
||||
if (!presetNeId || presetDoneRef.current) return;
|
||||
let cancelled = false;
|
||||
if (!presetNeId) return;
|
||||
const neId = presetNeId;
|
||||
const sourceHint = presetSource === "ume" ? "ume" : "managed";
|
||||
let alive = true;
|
||||
|
||||
(async () => {
|
||||
presetDoneRef.current = true;
|
||||
try {
|
||||
if (presetSource === "ume") {
|
||||
await openTarget({
|
||||
if (sourceHint === "ume") {
|
||||
await openTargetRef.current({
|
||||
source: "ume",
|
||||
id: presetNeId,
|
||||
ume_ne_id: presetNeId,
|
||||
name: presetNeId,
|
||||
id: neId,
|
||||
ume_ne_id: neId,
|
||||
name: neId,
|
||||
ip_address: "",
|
||||
connect_status: "unknown",
|
||||
});
|
||||
} else {
|
||||
const hit = items.find((x) => x.source === "managed" && x.id === presetNeId);
|
||||
if (hit) {
|
||||
await openTarget(hit);
|
||||
} else {
|
||||
const row = await fetchManagedNeById(presetNeId);
|
||||
if (cancelled) return;
|
||||
await openTarget({
|
||||
source: "managed",
|
||||
id: row.id,
|
||||
name: row.name || row.ip_address,
|
||||
ip_address: row.ip_address,
|
||||
vendor: row.vendor,
|
||||
device_type: row.device_type,
|
||||
connect_status: row.connect_status,
|
||||
cli_profile_ready: true,
|
||||
});
|
||||
}
|
||||
const row = await fetchManagedNeById(neId);
|
||||
await openTargetRef.current({
|
||||
source: "managed",
|
||||
id: row.id,
|
||||
name: row.name || row.ip_address,
|
||||
ip_address: row.ip_address,
|
||||
vendor: row.vendor,
|
||||
device_type: row.device_type,
|
||||
connect_status: row.connect_status,
|
||||
cli_profile_ready: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) showError(webcrtErrorMessage(err, t));
|
||||
if (alive) showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSearchParams({}, { replace: true });
|
||||
if (!alive) return;
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (next.get("ne_id") === neId) {
|
||||
next.delete("ne_id");
|
||||
next.delete("source");
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [presetNeId]);
|
||||
}, [presetNeId, presetSource, showError, t, setSearchParams]);
|
||||
|
||||
const activeTab = tabs.find((x) => x.key === activeTabKey) || null;
|
||||
const perPage = (n: number) => t("common.perPage", { n });
|
||||
|
||||
return (
|
||||
<div className="webcrt-shell">
|
||||
<aside className="webcrt-sidebar">
|
||||
<div className="webcrt-sidebar__title">{t("webcrt.sessionManager")}</div>
|
||||
<div className="webcrt-sidebar__toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="webcrt-icon-btn"
|
||||
title={t("webcrt.connect")}
|
||||
disabled={!selected}
|
||||
onClick={() => selected && void openTarget(selected)}
|
||||
>
|
||||
⚡
|
||||
</button>
|
||||
<div className="webcrt-sidebar__header">
|
||||
<div className="webcrt-sidebar__title">{t("webcrt.deviceList")}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="webcrt-icon-btn"
|
||||
|
|
@ -249,7 +322,6 @@ export function WebcrtPage() {
|
|||
))}
|
||||
</div>
|
||||
<div className="webcrt-tree">
|
||||
<div className="webcrt-tree__folder">Sessions</div>
|
||||
{targetsQuery.isLoading ? <div className="webcrt-tree__empty">{t("common.refreshing")}</div> : null}
|
||||
{!targetsQuery.isLoading && items.length === 0 ? (
|
||||
<div className="webcrt-tree__empty">{t("webcrt.empty")}</div>
|
||||
|
|
@ -263,18 +335,17 @@ export function WebcrtPage() {
|
|||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
className={`webcrt-tree__item${selectedKey === key ? " is-selected" : ""}${activeTabKey === key ? " is-active" : ""}`}
|
||||
onClick={() => setSelectedKey(key)}
|
||||
onDoubleClick={() => void openTarget(row)}
|
||||
title={`${row.name}\n${row.ip_address}\n${row.source}`}
|
||||
className={`webcrt-tree__item${activeTabKey === key ? " is-active" : ""}`}
|
||||
onClick={() => void openTarget(row)}
|
||||
title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
|
||||
>
|
||||
<span className="webcrt-tree__icon" aria-hidden>
|
||||
▣
|
||||
</span>
|
||||
<span className="webcrt-tree__label">
|
||||
<span className="webcrt-tree__name">{row.ip_address || row.name}</span>
|
||||
<span className="webcrt-tree__name">{deviceLabel(row)}</span>
|
||||
<span className="webcrt-tree__meta">
|
||||
{row.source === "ume" ? "UME" : "NE"}
|
||||
{row.ip_address || row.source}
|
||||
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
|
||||
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
|
||||
</span>
|
||||
|
|
@ -286,9 +357,7 @@ export function WebcrtPage() {
|
|||
</ul>
|
||||
</div>
|
||||
<div className="webcrt-sidebar__pager">
|
||||
<span>
|
||||
{t("common.pagerMeta", { total, page, pages })}
|
||||
</span>
|
||||
<span>{t("common.pagerMeta", { total, page, pages })}</span>
|
||||
<div className="webcrt-sidebar__pager-btns">
|
||||
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
‹
|
||||
|
|
@ -297,12 +366,6 @@ export function WebcrtPage() {
|
|||
›
|
||||
</button>
|
||||
</div>
|
||||
<span>{perPage(PAGE_SIZE)}</span>
|
||||
</div>
|
||||
<div className="webcrt-sidebar__footer">
|
||||
<button type="button" className="is-active">
|
||||
{t("webcrt.sessionManager")}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
|
@ -317,7 +380,7 @@ export function WebcrtPage() {
|
|||
onClick={() => setActiveTabKey(tab.key)}
|
||||
>
|
||||
<span>
|
||||
{tab.target.ip_address || tab.target.name}
|
||||
{deviceLabel(tab.target)}
|
||||
{tab.status === "connecting" ? ` (${t("webcrt.status.connecting")})` : ""}
|
||||
</span>
|
||||
<button
|
||||
|
|
@ -334,10 +397,34 @@ export function WebcrtPage() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{activeTab ? (
|
||||
<div className="webcrt-actions">
|
||||
<button type="button" className="webcrt-action-btn" onClick={() => void reconnectActive()}>
|
||||
<span aria-hidden>↻</span>
|
||||
{t("webcrt.actions.reconnect")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`webcrt-action-btn${activeTab.recording ? " is-active" : ""}`}
|
||||
onClick={toggleRecording}
|
||||
>
|
||||
<span aria-hidden>☐</span>
|
||||
{t("webcrt.actions.recordLog")}
|
||||
</button>
|
||||
<button type="button" className="webcrt-action-btn" onClick={clearActive}>
|
||||
<span aria-hidden>⌫</span>
|
||||
{t("webcrt.actions.clear")}
|
||||
</button>
|
||||
<button type="button" className="webcrt-action-btn" onClick={() => void copyActive()}>
|
||||
<span aria-hidden>⧉</span>
|
||||
{t("webcrt.actions.copy")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="webcrt-main__body">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.key}
|
||||
key={`${tab.key}:${tab.termEpoch}`}
|
||||
className="webcrt-main__pane"
|
||||
hidden={activeTabKey !== tab.key}
|
||||
>
|
||||
|
|
@ -352,8 +439,17 @@ export function WebcrtPage() {
|
|||
) : null}
|
||||
{tab.wsUrl ? (
|
||||
<WebTerminal
|
||||
ref={(handle) => {
|
||||
if (handle) termRefs.current.set(tab.key, handle);
|
||||
else termRefs.current.delete(tab.key);
|
||||
}}
|
||||
wsUrl={tab.wsUrl}
|
||||
title={tab.target.ip_address || tab.target.name}
|
||||
title={deviceLabel(tab.target)}
|
||||
recording={tab.recording}
|
||||
onStdout={(chunk) => {
|
||||
const prev = logBuffersRef.current.get(tab.key) || "";
|
||||
logBuffersRef.current.set(tab.key, prev + chunk);
|
||||
}}
|
||||
onStatus={(state) => {
|
||||
if (state === "open" || state === "connected") updateTab(tab.key, { status: "connected" });
|
||||
else if (state === "error") updateTab(tab.key, { status: "error" });
|
||||
|
|
@ -371,12 +467,6 @@ export function WebcrtPage() {
|
|||
<p className="panel__hint">{t("webcrt.hintCrt")}</p>
|
||||
</div>
|
||||
)}
|
||||
{activeTab ? (
|
||||
<div className="webcrt-statusline">
|
||||
{activeTab.target.name} · {activeTab.target.ip_address} · {activeTab.target.source} ·{" "}
|
||||
{t(`webcrt.status.${activeTab.status}`)}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,8 +30,12 @@ function moduleAckKey(moduleId: string): string {
|
|||
}
|
||||
|
||||
export function registerModuleWindow(moduleId: string): () => void {
|
||||
const sharedName = moduleWindowName(moduleId);
|
||||
try {
|
||||
window.name = moduleWindowName(moduleId);
|
||||
// Keep unique names from openNewModuleWindow; only claim the singleton if unnamed.
|
||||
if (!window.name) {
|
||||
window.name = sharedName;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
|
@ -40,9 +44,29 @@ export function registerModuleWindow(moduleId: string): () => void {
|
|||
MODULE_CHANNEL,
|
||||
moduleAckKey(moduleId),
|
||||
(data) => {
|
||||
// One-shot session windows (unique name) must not merge into the singleton module tab.
|
||||
if (window.name && window.name !== sharedName) {
|
||||
return;
|
||||
}
|
||||
const targetPath = String(data.path || "").trim();
|
||||
if (targetPath && window.location.pathname !== targetPath) {
|
||||
window.location.assign(targetPath);
|
||||
if (targetPath) {
|
||||
// Compare pathname only for hard navigation. Same route + new query
|
||||
// must soft-navigate so React state (open tabs) survives.
|
||||
try {
|
||||
const url = new URL(targetPath, window.location.origin);
|
||||
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||
const cur = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (window.location.pathname !== url.pathname) {
|
||||
window.location.assign(next);
|
||||
} else if (cur !== next) {
|
||||
window.history.pushState({}, "", next);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
} catch {
|
||||
if (window.location.pathname + window.location.search !== targetPath) {
|
||||
window.location.assign(targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
window.focus();
|
||||
},
|
||||
|
|
@ -74,4 +98,16 @@ export function openOrFocusModule({ moduleId, path }: ModuleWindowSpec): void {
|
|||
});
|
||||
}
|
||||
|
||||
/** Always open a fresh browser tab/window; never focus or merge with an existing module tab. */
|
||||
export function openNewModuleWindow({ moduleId, path }: ModuleWindowSpec): void {
|
||||
const mod = getModuleById(moduleId);
|
||||
const base = mod?.path ?? "/";
|
||||
const targetPath = path && (path === base || path.startsWith(`${base}?`) || path.startsWith(`${base}/`))
|
||||
? path
|
||||
: base;
|
||||
const uniqueName = `${moduleWindowName(moduleId)}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
focusWindowSafe(window.open(targetPath, uniqueName));
|
||||
}
|
||||
|
||||
|
||||
export { moduleIdFromPath } from "../config/modules";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue