From f6b399e03ee4afcfb066b1231aefbadeb48c8180 Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 1 Aug 2026 22:52:31 +0800 Subject: [PATCH] Improve WebCRT new-session flow for SecureCRT-like SSH/Telnet use. Add quick-connect sessions with credential retry, raw interactive drivers, bootstrap banner replay, Huawei telnet prompt cleanup, and session rename/delete in the tree. Co-authored-by: Cursor --- netx_api/cli_schemas.py | 3 + netx_api/cli_service.py | 98 +- netx_api/config.py | 4 +- netx_api/device_types.py | 6 + netx_api/main.py | 17 + netx_api/models.py | 4 +- netx_api/ne_cli_errors.py | 12 + netx_api/ne_netmiko.py | 15 +- netx_api/ne_service.py | 195 ++- netx_api/ne_session_factory.py | 16 +- netx_api/webcrt_router.py | 173 ++- netx_api/webcrt_service.py | 183 ++- tests/test_managed_ne.py | 189 ++- tests/test_ne_cli_errors.py | 7 + tests/test_webcrt.py | 137 ++ web/src/components/WebTerminal.tsx | 151 +- web/src/i18n/en.ts | 92 +- web/src/i18n/zh.ts | 90 +- web/src/index.css | 507 ++++++- web/src/pages/WebcrtPage.tsx | 1734 +++++++++++++++++++++-- web/src/services/api.ts | 82 +- web/src/types.ts | 3 + web/src/utils/webcrtKeywordHighlight.ts | 189 +++ 23 files changed, 3682 insertions(+), 225 deletions(-) create mode 100644 web/src/utils/webcrtKeywordHighlight.ts diff --git a/netx_api/cli_schemas.py b/netx_api/cli_schemas.py index b619399..8ca4151 100644 --- a/netx_api/cli_schemas.py +++ b/netx_api/cli_schemas.py @@ -118,5 +118,8 @@ class CliTargetOut(BaseModel): ne_type: str = "" vendor: str = "" device_type: str = "" + protocol: str = "" + username: str = "" + has_password: bool = False connect_status: str = "unknown" cli_profile_ready: bool = False diff --git a/netx_api/cli_service.py b/netx_api/cli_service.py index 9bfeec3..b7fe63e 100644 --- a/netx_api/cli_service.py +++ b/netx_api/cli_service.py @@ -258,8 +258,10 @@ def list_cli_targets( page: int = 1, page_size: int = 50, ) -> dict[str, Any]: + from .device_types import WEBCRT_NE_SOURCE + src = str(source or "all").strip().lower() - if src not in ("managed", "ume", "all"): + if src not in ("managed", "ume", "webcrt", "all"): raise HTTPException(status_code=400, detail="invalid_source") ready = cli_profile_ready(db) page = max(1, int(page or 1)) @@ -267,15 +269,18 @@ def list_cli_targets( offset = (page - 1) * page_size kw = str(keyword or "").strip() - def _managed_item(row: Any) -> dict[str, Any]: + def _managed_item(row: Any, *, list_source: str = "managed") -> dict[str, Any]: return CliTargetOut( - source="managed", + source=list_source, id=str(row.id), ume_ne_id=None, name=str(row.name or row.ip_address), ip_address=str(row.ip_address), vendor=str(row.vendor), device_type=str(row.device_type), + protocol=str(getattr(row, "protocol", "") or ""), + username=str(getattr(row, "username", "") or ""), + has_password=bool(str(getattr(row, "password_enc", "") or "").strip()), connect_status=str(row.connect_status), cli_profile_ready=True, ).model_dump() @@ -293,19 +298,27 @@ def list_cli_targets( cli_profile_ready=ready, ).model_dump() - def _managed_query(): - stmt = db.query(ManagedNE) - if kw: - like = f"%{kw}%" - stmt = stmt.filter( - ManagedNE.name.ilike(like) - | ManagedNE.ip_address.ilike(like) - | ManagedNE.username.ilike(like) - | ManagedNE.tags.ilike(like) - | ManagedNE.vendor.ilike(like) - | ManagedNE.device_type.ilike(like) - ) - return stmt.order_by(ManagedNE.updated_at.desc()) + def _apply_kw(stmt: Any) -> Any: + if not kw: + return stmt + like = f"%{kw}%" + return stmt.filter( + ManagedNE.name.ilike(like) + | ManagedNE.ip_address.ilike(like) + | ManagedNE.username.ilike(like) + | ManagedNE.tags.ilike(like) + | ManagedNE.vendor.ilike(like) + | ManagedNE.device_type.ilike(like) + ) + + def _inventory_managed_query(): + # Asset inventory for WebCRT: exclude Quick-Connect (source=webcrt) rows. + stmt = db.query(ManagedNE).filter(ManagedNE.source != WEBCRT_NE_SOURCE) + return _apply_kw(stmt).order_by(ManagedNE.updated_at.desc()) + + def _webcrt_query(): + stmt = db.query(ManagedNE).filter(ManagedNE.source == WEBCRT_NE_SOURCE) + return _apply_kw(stmt).order_by(ManagedNE.updated_at.desc()) def _ume_query(): stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin( @@ -323,10 +336,17 @@ def list_cli_targets( return stmt.order_by(UmeInventoryNE.ne_id.asc()) if src == "managed": - mq = _managed_query() + mq = _inventory_managed_query() total = int(mq.count()) rows = mq.offset(offset).limit(page_size).all() - items = [_managed_item(x) for x in rows] + items = [_managed_item(x, list_source="managed") for x in rows] + return {"items": items, "total": total, "page": page, "page_size": page_size} + + if src == "webcrt": + wq = _webcrt_query() + total = int(wq.count()) + rows = wq.offset(offset).limit(page_size).all() + items = [_managed_item(x, list_source="webcrt") for x in rows] return {"items": items, "total": total, "page": page, "page_size": page_size} if src == "ume": @@ -336,24 +356,40 @@ def list_cli_targets( items = [_ume_item(inv, ov) for inv, ov in rows] return {"items": items, "total": total, "page": page, "page_size": page_size} - # source=all: managed first, then UME, with correct cross-list pagination - mq = _managed_query() + # source=all: inventory managed → webcrt sessions → UME + mq = _inventory_managed_query() + wq = _webcrt_query() uq = _ume_query() m_total = int(mq.count()) + w_total = int(wq.count()) u_total = int(uq.count()) - total = m_total + u_total + total = m_total + w_total + u_total items: list[dict[str, Any]] = [] - if offset < m_total: - take = min(page_size, m_total - offset) - for row in mq.offset(offset).limit(take).all(): - items.append(_managed_item(row)) - need = page_size - len(items) - if need > 0 and u_total > 0: - for inv, ov in uq.offset(0).limit(need).all(): - items.append(_ume_item(inv, ov)) + + remaining = page_size + cursor = offset + + if cursor < m_total and remaining > 0: + take = min(remaining, m_total - cursor) + for row in mq.offset(cursor).limit(take).all(): + items.append(_managed_item(row, list_source="managed")) + remaining -= take + cursor = 0 else: - u_off = offset - m_total - for inv, ov in uq.offset(u_off).limit(page_size).all(): + cursor = max(0, cursor - m_total) + + if remaining > 0: + if cursor < w_total: + take = min(remaining, w_total - cursor) + for row in wq.offset(cursor).limit(take).all(): + items.append(_managed_item(row, list_source="webcrt")) + remaining -= take + cursor = 0 + else: + cursor = max(0, cursor - w_total) + + if remaining > 0: + for inv, ov in uq.offset(cursor).limit(remaining).all(): items.append(_ume_item(inv, ov)) return {"items": items, "total": total, "page": page, "page_size": page_size} diff --git a/netx_api/config.py b/netx_api/config.py index 52ad0f3..e68fbe9 100644 --- a/netx_api/config.py +++ b/netx_api/config.py @@ -88,8 +88,8 @@ class Settings(BaseSettings): webcrt_connect_timeout_sec: int = 90 webcrt_attach_timeout_sec: int = 60 webcrt_data_dir: str = "data/webcrt" - # SSH transport keepalive interval (seconds); 0 disables. - webcrt_keepalive_sec: int = 30 + # SSH transport keepalive interval (seconds); 0 disables (default off). + webcrt_keepalive_sec: int = 0 # Device anti-idle CLI nudge (0 = off). Keep off: NEs close idle VTY themselves. webcrt_anti_idle_sec: int = 0 webcrt_anti_idle_payload: str = " " diff --git a/netx_api/device_types.py b/netx_api/device_types.py index f9e2baa..773bfc7 100644 --- a/netx_api/device_types.py +++ b/netx_api/device_types.py @@ -43,3 +43,9 @@ SUPPORTED_VENDORS: tuple[str, ...] = ( "Nokia", "Other", ) + +# WebCRT "New Session": inventory types + raw interactive hosts (generic/linux). +WEBCRT_DEVICE_TYPES: tuple[str, ...] = SUPPORTED_DEVICE_TYPES + ("linux", "generic") + +# ManagedNE.source value for sessions created via WebCRT Quick Connect. +WEBCRT_NE_SOURCE = "webcrt" diff --git a/netx_api/main.py b/netx_api/main.py index 7170d47..056dc8c 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -975,6 +975,23 @@ def on_startup() -> None: conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source VARCHAR(64) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source_ref VARCHAR(128) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT DEFAULT ''") + # WebCRT sessions may share a host IP; uniqueness is enforced in ne_service for inventory only. + for stmt in ( + "ALTER TABLE managed_ne DROP CONSTRAINT IF EXISTS managed_ne_ip_address_key", + "DROP INDEX IF EXISTS managed_ne_ip_address_key", + "DROP INDEX IF EXISTS ix_managed_ne_ip_address", + "DROP INDEX IF EXISTS sqlite_autoindex_managed_ne_1", + ): + try: + conn.exec_driver_sql(stmt) + except Exception: + pass + try: + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_managed_ne_ip_address ON managed_ne (ip_address)" + ) + except Exception: + pass conn.exec_driver_sql( "ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP" ) diff --git a/netx_api/models.py b/netx_api/models.py index 0839887..4f8a8d5 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -278,7 +278,9 @@ class ManagedNE(Base): name: Mapped[str] = mapped_column(String(256), default="", index=True) vendor: Mapped[str] = mapped_column(String(64), default="Other", index=True) device_type: Mapped[str] = mapped_column(String(128), default="") - ip_address: Mapped[str] = mapped_column(String(128), unique=True, index=True) + # Not unique: WebCRT sessions may share a host IP with distinct session names. + # Inventory create/update still enforces uniqueness in ne_service. + ip_address: Mapped[str] = mapped_column(String(128), index=True) port: Mapped[int] = mapped_column(Integer, default=22) protocol: Mapped[str] = mapped_column(String(16), default="ssh") username: Mapped[str] = mapped_column(String(128), default="") diff --git a/netx_api/ne_cli_errors.py b/netx_api/ne_cli_errors.py index d551306..dacc7af 100644 --- a/netx_api/ne_cli_errors.py +++ b/netx_api/ne_cli_errors.py @@ -54,6 +54,18 @@ def format_cli_failure(exc: BaseException | str, transcript: str = "", *, limit: """Human/ops-facing failure message; promote auth rejects above Pattern/ReadTimeout.""" if isinstance(exc, BaseException): exc_text = f"{type(exc).__name__}: {exc}" + # Paramiko/Netmiko auth exceptions may carry little/no message text. + try: + import paramiko + + if isinstance(exc, paramiko.AuthenticationException): + detail = str(exc).strip() or type(exc).__name__ + return f"auth_rejected: {detail}"[:limit] + except Exception: + pass + if "AuthenticationException" in type(exc).__name__: + detail = str(exc).strip() or type(exc).__name__ + return f"auth_rejected: {detail}"[:limit] else: exc_text = str(exc or "") combined = f"{exc_text}\n{transcript or ''}" diff --git a/netx_api/ne_netmiko.py b/netx_api/ne_netmiko.py index e60620a..0cbeda6 100644 --- a/netx_api/ne_netmiko.py +++ b/netx_api/ne_netmiko.py @@ -8,13 +8,22 @@ from typing import Any def normalize_netmiko_device_type(device_type: str, protocol: str) -> str: dt = str(device_type or "").strip() proto = str(protocol or "ssh").strip().lower() - if "zte" in dt.lower(): + low = dt.lower() + # Raw / SecureCRT-style sessions (WebCRT quick-connect stores device_type=generic). + if low in ("generic", "generic_ssh", "generic_telnet", "terminal_server", "generic_termserver"): + return "generic_telnet" if proto == "telnet" or "telnet" in low else "generic_termserver_ssh" + # Netmiko ships linux / linux_ssh but not linux_telnet — use generic_telnet. + if low in ("linux", "linux_ssh", "linux_telnet") or low.startswith("linux_"): + if proto == "telnet" or "telnet" in low: + return "generic_telnet" + return "linux_ssh" + if "zte" in low: if dt == "zte": return f"zte_zxros_{proto}" - if "telnet" not in dt and "ssh" not in dt: + if "telnet" not in low and "ssh" not in low: return f"{dt}_{proto}" return dt - if "telnet" not in dt and "ssh" not in dt: + if "telnet" not in low and "ssh" not in low: return f"{dt}_{proto}" return dt diff --git a/netx_api/ne_service.py b/netx_api/ne_service.py index 06cff18..7f76da2 100644 --- a/netx_api/ne_service.py +++ b/netx_api/ne_service.py @@ -10,7 +10,12 @@ from fastapi import HTTPException from sqlalchemy import or_ from sqlalchemy.orm import Session -from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS +from .device_types import ( + SUPPORTED_DEVICE_TYPES, + SUPPORTED_VENDORS, + WEBCRT_DEVICE_TYPES, + WEBCRT_NE_SOURCE, +) from .models import ManagedNE, UmeInventoryNE from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_secret, encrypt_secret from .ne_schemas import ( @@ -41,6 +46,10 @@ IMPORT_COLUMNS = ( UME_SYNC_SOURCE = "ume_sync" UME_SYNC_TAG = "UME" +# Re-export for callers (WebCRT quick-connect). +WEBCRT_SOURCE = WEBCRT_NE_SOURCE +# Re-export for callers (WebCRT Quick Connect). +WEBCRT_SOURCE = WEBCRT_NE_SOURCE _BUILTIN_NE_TYPE_RULES: list[tuple[re.Pattern[str], str, str]] = [ (re.compile(r"ZXR|ZXCTN|M6000|\bBN\b", re.I), "zte_zxros", "ZTE"), (re.compile(r"NE40|CE\b|ATN|MA5800|OptiX", re.I), "huawei", "Huawei"), @@ -313,6 +322,190 @@ def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut: return row_to_out(row) +def _normalize_webcrt_device_type(device_type: str) -> str: + dt = str(device_type or "").strip() + low = dt.lower() + if low in ("linux", "linux_ssh", "linux_telnet"): + return "linux" + if low in ("generic", "generic_ssh", "generic_telnet", "terminal_server", "generic_termserver"): + return "generic" + return dt + + +def upsert_webcrt_managed_ne(db: Session, body: ManagedNeCreate) -> tuple[ManagedNeOut, str]: + """Create/update a WebCRT-origin NE, or reuse an existing inventory NE by IP. + + Returns ``(ne_out, action)`` where action is ``created`` | ``updated`` | ``reused``. + """ + _require_crypto() + _validate_hop_on_create(body) + ip = _normalize_ip(body.ip_address) + if not ip: + raise HTTPException(status_code=400, detail="ip_address_required") + if not str(body.username or "").strip(): + raise HTTPException(status_code=400, detail="cli_username_required") + device_type = _normalize_webcrt_device_type(body.device_type) + if device_type not in WEBCRT_DEVICE_TYPES: + raise HTTPException(status_code=400, detail="unsupported_device_type") + + existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first() + now = _now() + + if existing is not None: + src = str(existing.source or "").strip() + if src != WEBCRT_NE_SOURCE: + # Do not overwrite inventory / UME-synced assets; just open them. + return row_to_out(existing), "reused" + + existing.name = str(body.name or "").strip() or existing.name or ip + existing.vendor = _normalize_vendor(body.vendor) if str(body.vendor or "").strip() else ( + "Other" if device_type == "linux" else existing.vendor + ) + existing.device_type = device_type + existing.port = int(body.port or existing.port or 22) + existing.protocol = _normalize_protocol(body.protocol) + existing.username = str(body.username or "").strip() + if str(body.password or "").strip(): + existing.password_enc = encrypt_secret(body.password) + elif not str(existing.password_enc or "").strip() and not ( + body.hop_enabled + and _normalize_hop_vendor(body.hop_vendor) == "bastion" + and _normalize_hop_target_auth_mode(body.hop_target_auth_mode) == "bastion_managed" + ): + raise HTTPException(status_code=400, detail="password_required") + existing.source = WEBCRT_NE_SOURCE + _apply_hop_create(existing, body) + existing.updated_at = now + db.commit() + db.refresh(existing) + return row_to_out(existing), "updated" + + if not str(body.password or "").strip() and not ( + body.hop_enabled + and _normalize_hop_vendor(body.hop_vendor) == "bastion" + and _normalize_hop_target_auth_mode(body.hop_target_auth_mode) == "bastion_managed" + ): + raise HTTPException(status_code=400, detail="password_required") + + vendor = _normalize_vendor(body.vendor) + if device_type == "linux" and not str(body.vendor or "").strip(): + vendor = "Other" + + row = ManagedNE( + name=str(body.name or "").strip() or ip, + vendor=vendor, + device_type=device_type, + ip_address=ip, + port=int(body.port or 22), + protocol=_normalize_protocol(body.protocol), + username=str(body.username or "").strip(), + password_enc=encrypt_secret(body.password) if str(body.password or "").strip() else "", + enable_secret_enc="", + connect_status="unknown", + tags=str(body.tags or "").strip(), + remark=str(body.remark or "").strip(), + source=WEBCRT_NE_SOURCE, + source_ref="", + created_at=now, + updated_at=now, + ) + _apply_hop_create(row, body) + db.add(row) + db.commit() + db.refresh(row) + return row_to_out(row), "created" + + +def _next_webcrt_session_name(db: Session, base: str) -> str: + """Return base, or ``base (1)``, ``base (2)``, … among WebCRT session names.""" + root = str(base or "").strip() or "session" + rows = ( + db.query(ManagedNE.name) + .filter(ManagedNE.source == WEBCRT_NE_SOURCE) + .all() + ) + taken = {str(r[0] or "").strip() for r in rows if str(r[0] or "").strip()} + if root not in taken: + return root + n = 1 + while f"{root} ({n})" in taken: + n += 1 + return f"{root} ({n})" + + +def upsert_webcrt_session_host( + db: Session, + *, + name: str = "", + ip_address: str, + port: int = 22, + protocol: str = "ssh", + username: str = "", + password: str = "", + save_password: bool = False, +) -> tuple[ManagedNeOut, str]: + """Create a WebCRT session host (linux, no hop). Always inserts a new row. + + Same IP is allowed; session name auto-suffixes ``(1)``, ``(2)``, … on collision. + Telnet never persists a password. SSH persists password only when ``save_password``. + Returns ``(ne_out, \"created\")``. + """ + _require_crypto() + ip = _normalize_ip(ip_address) + if not ip: + raise HTTPException(status_code=400, detail="ip_address_required") + proto = _normalize_protocol(protocol) + user = str(username or "").strip() + pwd = str(password or "") + if proto == "ssh" and not user: + raise HTTPException(status_code=400, detail="cli_username_required") + if proto == "ssh" and save_password and not pwd.strip(): + raise HTTPException(status_code=400, detail="password_required") + + now = _now() + display_name = _next_webcrt_session_name(db, str(name or "").strip() or ip) + + password_enc = "" + if proto == "ssh" and save_password and pwd.strip(): + password_enc = encrypt_secret(pwd) + + row = ManagedNE( + name=display_name, + vendor="Other", + # generic → Netmiko terminal_server: SSH auth then raw PTY (no linux session prep). + device_type="generic", + ip_address=ip, + port=int(port or (23 if proto == "telnet" else 22)), + protocol=proto, + username=user, + password_enc=password_enc, + enable_secret_enc="", + connect_status="unknown", + tags="", + remark="", + source=WEBCRT_NE_SOURCE, + source_ref="", + created_at=now, + updated_at=now, + ) + db.add(row) + try: + db.commit() + except Exception as exc: + db.rollback() + # Stale unique index on ip_address → restart API after migration, or drop constraint manually. + from sqlalchemy.exc import IntegrityError + + if isinstance(exc, IntegrityError): + raise HTTPException( + status_code=409, + detail="ip_address_conflict_restart_required", + ) from exc + raise + db.refresh(row) + return row_to_out(row), "created" + + def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> ManagedNeOut: row = db.get(ManagedNE, ne_id) if not row: diff --git a/netx_api/ne_session_factory.py b/netx_api/ne_session_factory.py index 7756af7..6891743 100644 --- a/netx_api/ne_session_factory.py +++ b/netx_api/ne_session_factory.py @@ -243,9 +243,10 @@ def _netmiko_driver_class(device_type: str) -> type: def _interactive_driver_class(base_cls: type) -> type: - """Subclass that skips Netmiko auto terminal-length / terminal-width commands. + """Subclass for WebCRT: raw interactive PTY after transport auth (SecureCRT-like). - Collection and MCP exec need paging disabled; WebCRT must not inject those. + Skips Netmiko session prep (prompt discovery, terminal length/width, force RETURN) + so the channel is left for the user — not consumed by library automation. """ class _InteractiveSession(base_cls): # type: ignore[misc,valid-type] @@ -255,6 +256,17 @@ def _interactive_driver_class(base_cls: type) -> type: def set_terminal_width(self, *args: Any, **kwargs: Any) -> str: # noqa: ANN401 return "" + def session_preparation(self) -> None: + return None + + def _try_session_preparation(self, force_data: bool = True) -> None: # noqa: FBT001, FBT002 + del force_data + try: + self.session_preparation() + except Exception: + self.disconnect() + raise + _InteractiveSession.__name__ = f"Interactive{getattr(base_cls, '__name__', 'Netmiko')}" return _InteractiveSession diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index 6b98dbc..576c2dd 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import json import logging +import time from typing import Any from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect @@ -37,9 +38,32 @@ class WebcrtSessionCreate(BaseModel): cols: int = Field(default=80, ge=20, le=500) rows: int = Field(default=24, ge=5, le=200) encoding: str = Field(default="utf-8") + # SSH transport keepalive interval (seconds). None = server default; 0 = off. + keepalive_sec: int | None = Field(default=None, ge=0, le=600) post_login_commands: list[str] = Field(default_factory=list) # Default async so UI can open WS while connect runs; tests may force sync via service API. async_connect: bool = Field(default=True) + # One-shot credentials (not written to DB). + username: str | None = None + password: str | None = None + + +class WebcrtQuickConnectBody(BaseModel): + """SecureCRT-style: upsert session host then open a session.""" + + name: str = "" + ip_address: str + port: int = 22 + protocol: str = "ssh" + username: str = "" + password: str = "" + save_password: bool = False + cols: int = Field(default=80, ge=20, le=500) + rows: int = Field(default=24, ge=5, le=200) + encoding: str = Field(default="utf-8") + keepalive_sec: int | None = Field(default=None, ge=0, le=600) + post_login_commands: list[str] = Field(default_factory=list) + async_connect: bool = Field(default=True) class WebcrtSftpListBody(BaseModel): @@ -68,6 +92,13 @@ def api_list_sessions() -> dict[str, Any]: return list_sessions() +@router.get("/meta/device-types") +def api_webcrt_device_types() -> dict[str, Any]: + from .device_types import SUPPORTED_VENDORS, WEBCRT_DEVICE_TYPES + + return {"device_types": list(WEBCRT_DEVICE_TYPES), "vendors": list(SUPPORTED_VENDORS)} + + @router.post("/sessions") def api_create_session( body: WebcrtSessionCreate, @@ -86,11 +117,82 @@ def api_create_session( rows=body.rows, client=_client_label(request=request), encoding=body.encoding, + keepalive_sec=body.keepalive_sec, post_login_commands=list(body.post_login_commands or [])[:20], async_connect=bool(body.async_connect), + username_override=body.username, + password_override=body.password, ) +@router.post("/sessions/quick-connect") +def api_quick_connect( + body: WebcrtQuickConnectBody, + request: Request, + db: Session = Depends(get_db), +) -> dict[str, Any]: + from .ne_service import upsert_webcrt_session_host + + proto = str(body.protocol or "ssh").strip().lower() + if proto not in ("ssh", "telnet"): + raise HTTPException(status_code=400, detail="invalid_protocol") + save_password = bool(body.save_password) and proto == "ssh" + ne_out, action = upsert_webcrt_session_host( + db, + name=body.name, + ip_address=body.ip_address, + port=body.port, + protocol=proto, + username=body.username, + password=body.password, + save_password=save_password, + ) + # Pass SSH credentials as one-shot overrides (covers unsaved password + reused inventory). + pwd_override: str | None = None + user_override: str | None = None + if proto == "ssh": + user_override = str(body.username or "").strip() or None + if str(body.password or "").strip(): + pwd_override = str(body.password) + # SSH with password: wait for auth so wrong credentials can re-prompt (SecureCRT-like). + wait_for_auth = proto == "ssh" and bool(pwd_override) + async_connect = bool(body.async_connect) and not wait_for_auth + try: + session = create_session( + db, + ne_id=ne_out.id, + cols=body.cols, + rows=body.rows, + client=_client_label(request=request), + encoding=body.encoding, + keepalive_sec=body.keepalive_sec, + post_login_commands=list(body.post_login_commands or [])[:20], + async_connect=async_connect, + username_override=user_override, + password_override=pwd_override, + ) + except HTTPException as exc: + # NE row already exists; return it so the UI retries in place (no duplicate hosts). + if exc.status_code == 502 and proto == "ssh": + raise HTTPException( + status_code=502, + detail={ + "error": "connect_failed", + "message": str(exc.detail or "connect_failed"), + "ne": ne_out.model_dump(mode="json"), + "ne_action": action, + "list_source": "webcrt", + }, + ) from exc + raise + return { + **session, + "ne": ne_out.model_dump(mode="json"), + "ne_action": action, + "list_source": "webcrt", + } + + @router.delete("/sessions/{session_id}") def api_close_session(session_id: str, request: Request) -> dict[str, Any]: return close_session(session_id, reason="client_delete", client=_client_label(request=request)) @@ -181,6 +283,8 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: { "type": "status", "state": "connecting" if sess.state == "connecting" else "connected", + "phase": "authenticating" if sess.state == "connecting" else "ready", + "message": "authenticating" if sess.state == "connecting" else "", "session_id": sess.session_id, "ne_id": sess.ne_id, "ne_name": sess.ne_name, @@ -195,24 +299,61 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: } ) - # Wait for async connect without blocking the event loop. + # Wait for async connect without blocking the event loop; emit phase updates. if sess.state == "connecting": loop = asyncio.get_running_loop() - try: - await loop.run_in_executor( - None, - lambda: wait_session_ready( - session_id, - timeout=max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15, - ), - ) - sess = get_session(session_id) or sess - except HTTPException as exc: - await websocket.send_json( - {"type": "status", "state": "error", "message": str(exc.detail)} - ) - await websocket.close(code=4502) - return + budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15 + deadline = time.time() + budget + while True: + cur = get_session(session_id) or sess + if cur.state != "connecting": + sess = cur + break + elapsed = max(0.0, time.time() - float(cur.connect_started_at or time.time())) + phase = "authenticating" if elapsed < 6.0 else "waiting_prompt" + try: + await websocket.send_json( + { + "type": "status", + "state": "connecting", + "phase": phase, + "message": phase, + "elapsed_ms": int(elapsed * 1000), + "session_id": cur.session_id, + } + ) + except Exception: + break + remaining = deadline - time.time() + if remaining <= 0: + await websocket.send_json( + {"type": "status", "state": "error", "message": "connect_timeout"} + ) + await websocket.close(code=4502) + return + slice_timeout = min(1.0, max(0.2, remaining)) + try: + await loop.run_in_executor( + None, + lambda t=slice_timeout: wait_session_ready(session_id, timeout=t), + ) + sess = get_session(session_id) or cur + break + except HTTPException as exc: + if exc.status_code == 504: + # Slice timeout while still connecting — keep polling with progress. + continue + await websocket.send_json( + {"type": "status", "state": "error", "message": str(exc.detail)} + ) + await websocket.close(code=4502) + return + except Exception as exc: + await websocket.send_json( + {"type": "status", "state": "error", "message": f"connect_failed:{exc}"} + ) + await websocket.close(code=4502) + return await websocket.send_json( { diff --git a/netx_api/webcrt_service.py b/netx_api/webcrt_service.py index 76489b4..f262aa2 100644 --- a/netx_api/webcrt_service.py +++ b/netx_api/webcrt_service.py @@ -64,7 +64,7 @@ def uses_network_cli_keymap(device_type: str = "", vendor: str = "") -> bool: blob = f"{device_type} {vendor}".strip().lower() if not blob: return True - for token in ("linux", "ubuntu", "centos", "debian", "redhat", "unix"): + for token in ("linux", "ubuntu", "centos", "debian", "redhat", "unix", "generic_telnet", "generic"): if token in blob: return False return True @@ -158,10 +158,35 @@ def _looks_like_cli_prompt(text: str) -> bool: s = str(text or "").rstrip() if not s: return False + # Buffer races can leave a stray ':' after Huawei ```` (from prior ``[Y/N]:``). + if s.endswith(":") and ">" in s: + s = s[:-1].rstrip() # Common network CLI prompts: [HUAWEI] Router# Router> return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s)) +def _looks_like_login_prompt(text: str) -> bool: + """True when the transcript ends at Username:/Login:/Password: (interactive auth).""" + s = str(text or "").replace("\r\n", "\n").replace("\r", "\n") + lines = [ln.strip() for ln in s.split("\n") if ln.strip()] + if not lines: + return False + last = lines[-1] + return bool(re.search(r"(?i)(user\s*name|login|password)\s*:\s*$", last)) + + +def _looks_like_password_change_prompt(text: str) -> bool: + """Huawei/VRP post-auth ``Change now? [Y/N]:`` (Netmiko already answers N).""" + s = str(text or "").replace("\r\n", "\n").replace("\r", "\n") + lines = [ln.strip() for ln in s.split("\n") if ln.strip()] + if not lines: + return False + last = lines[-1] + return bool(re.search(r"(?i)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:\s*$", last)) or bool( + re.search(r"\[Y/N\]\s*:\s*$", last, flags=re.I) + ) + + # 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})[#>])") @@ -188,47 +213,74 @@ def prepare_bootstrap_output(text: str) -> str: Trailing newline would leave the cursor on a blank line so the first typed line looks wrong; cursor should sit after the prompt like a real CRT. """ - return normalize_cli_transcript(text) + s = normalize_cli_transcript(text) + # Drop a stray ':' glued onto Huawei ```` after ``[Y/N]:`` buffer races. + s = re.sub(r"(<[^\r\n>]+>):\s*$", r"\1", s) + return s -def _drain_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> None: - """Discard leftover bytes on the live channel (SSH/Telnet) after login priming.""" +def _capture_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> str: + """Read leftover PTY bytes into text (banner/MOTD after SSH auth). + + Interactive WebCRT skips Netmiko session_preparation, so the post-auth banner + often never lands in ``session_log`` and must be pulled from the live channel. + """ + chunks: list[str] = [] channel = getattr(conn, "remote_conn", None) if channel is None: try: - _drain_channel(conn, rounds=max(2, int(duration / 0.05)), wait=0.05) + return _drain_channel(conn, rounds=max(2, int(duration / 0.05)), wait=0.05) except Exception: - pass - return + return "" end = time.time() + max(0.1, float(duration)) while time.time() < end: got = False try: - if hasattr(channel, "recv_ready") and hasattr(channel, "recv") and channel.recv_ready(): - channel.recv(65535) - got = True - elif hasattr(channel, "read_very_eager"): + # Paramiko SSH channel + if hasattr(channel, "recv_ready") and hasattr(channel, "recv"): + if channel.recv_ready(): + raw = channel.recv(65535) + if raw: + got = True + if isinstance(raw, bytes): + chunks.append(raw.decode("utf-8", errors="replace")) + else: + chunks.append(str(raw)) + # telnetlib-style + elif callable(getattr(channel, "read_very_eager", None)): data = channel.read_very_eager() if data: got = True + if isinstance(data, bytes): + chunks.append(data.decode("utf-8", errors="replace")) + else: + chunks.append(str(data)) else: part = conn.read_channel() if part: got = True + chunks.append(str(part)) except Exception: break if not got: time.sleep(0.04) + return "".join(chunks) -def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool = False) -> None: - """Send one RETURN after Netmiko login so the interactive channel is fully ready. +def _drain_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> None: + """Discard leftover bytes on the live channel (SSH/Telnet) after login priming.""" + _capture_raw_channel(conn, duration=duration) + + +def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool = False) -> str: + """Sync interactive channel after login; return captured banner/prompt text. Skip the sync Enter when the login transcript already ends with a CLI prompt — otherwise slow Cisco VMs accumulate duplicate ``R2#`` lines in the bootstrap. """ + parts: list[str] = [] try: - _drain_raw_channel(conn, duration=0.15) + parts.append(_capture_raw_channel(conn, duration=0.25)) except Exception: pass if not already_prompted: @@ -238,15 +290,16 @@ def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool = try: conn.write_channel("\n") except Exception: - return + return "".join(parts) try: - _drain_channel(conn, rounds=6, wait=0.08) + parts.append(_drain_channel(conn, rounds=6, wait=0.08)) except Exception: pass try: - _drain_raw_channel(conn, duration=0.35) + parts.append(_capture_raw_channel(conn, duration=0.35)) except Exception: pass + return "".join(parts) def _is_prompt_only_echo(text: str, prompt_hint: str = "") -> bool: @@ -361,6 +414,7 @@ class WebcrtSession: vendor: str = "" cli_keymap: bool = True encoding: str = "utf-8" + keepalive_sec: int = 0 conn: ConnectHandler | None = None created_at: float = field(default_factory=time.time) last_activity: float = field(default_factory=time.time) @@ -802,19 +856,25 @@ def _webcrt_creds_ready(creds: dict[str, Any]) -> bool: Bastion-managed hops store the target password on the bastion side, so an empty NE password is valid (same as connectivity test). Direct / manual / Linux hops - still require a target password. + still require a target password for SSH. + + Telnet (no hop) allows empty username/password so the user can authenticate + interactively in the terminal (SecureCRT-style). """ - if not str(creds.get("username") or "").strip(): - return False hop_enabled = bool(creds.get("hop_enabled")) hop_vendor = str(creds.get("hop_vendor") or "").strip().lower() auth_mode = str(creds.get("hop_target_auth_mode") or "bastion_managed").strip().lower() + protocol = str(creds.get("protocol") or "ssh").strip().lower() if hop_enabled and hop_vendor == "bastion" and auth_mode == "bastion_managed": return bool( str(creds.get("hop_host") or "").strip() and str(creds.get("hop_username") or "").strip() and str(creds.get("hop_password") or "") ) + if protocol == "telnet" and not hop_enabled: + return True + if not str(creds.get("username") or "").strip(): + return False return bool(str(creds.get("password") or "")) @@ -835,6 +895,7 @@ def _finish_connect( cols=sess.cols, rows=sess.rows, interactive=True, + keepalive=int(sess.keepalive_sec or 0), ) except Exception as exc: partial = _session_log_text(log_buf).strip() @@ -868,29 +929,57 @@ def _finish_connect( pass pre_log = _session_log_text(log_buf) - already_prompted = _looks_like_cli_prompt(pre_log) - _prime_interactive_channel(conn, already_prompted=already_prompted) - bootstrap = prepare_bootstrap_output(_session_log_text(log_buf)) - if not bootstrap.strip(): - try: - more = _drain_channel(conn, rounds=6, wait=0.08) - except Exception: - more = "" - if more: - bootstrap = prepare_bootstrap_output(more) - # Slow Cisco VMs leave extra prompt bytes; discard before the live reader starts. + # Pull post-auth banner/MOTD from the PTY. With interactive no-op session_preparation + # (generic_termserver), Netmiko session_log is often empty — do not discard these bytes. try: - _drain_raw_channel(conn, duration=0.55) + early = _capture_raw_channel(conn, duration=0.35) + except Exception: + early = "" + seed = f"{pre_log}{early}" + already_prompted = _looks_like_cli_prompt(seed) + primed = "" + # Do not send Enter at Username:/Password: or Huawei password-change [Y/N]: + # (Netmiko telnet_login already answers password-change with "N"). + if _looks_like_login_prompt(seed) or _looks_like_password_change_prompt(seed): + try: + primed = _capture_raw_channel(conn, duration=0.9) + except Exception: + primed = "" + else: + try: + primed = _prime_interactive_channel(conn, already_prompted=already_prompted) + except Exception: + primed = "" + combined = f"{seed}{primed}" + # Final settle: keep stragglers in bootstrap (normalize collapses duplicate prompts). + try: + combined += _capture_raw_channel(conn, duration=0.35) except Exception: pass + if not str(combined).strip(): + try: + combined = _drain_channel(conn, rounds=6, wait=0.08) + except Exception: + combined = "" + bootstrap = prepare_bootstrap_output(combined) + # Discard lone punctuation left on the wire (would glue onto ```` in xterm). + try: + leftover = _capture_raw_channel(conn, duration=0.12) + except Exception: + leftover = "" + if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}: + bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}") hop_guard = get_cli_hop_guard(conn) sess.conn = conn sess.cli_hop_guard = bool(hop_guard) sess.cli_hop_prompt = str((hop_guard or {}).get("hop_prompt") or "") sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding) - # Bootstrap already ends at a prompt → never nudge another Enter on WS attach. - sess.needs_live_prompt = not _looks_like_cli_prompt(bootstrap) + # Nudge Enter on WS attach only when we still need a shell prompt. + # Never when already at CLI prompt or Username:/Password: (would empty-submit login). + sess.needs_live_prompt = ( + not _looks_like_cli_prompt(bootstrap) and not _looks_like_login_prompt(bootstrap) + ) sess.open_session_log() if bootstrap: sess.append_session_log(bootstrap if bootstrap.endswith("\n") else bootstrap + "\n") @@ -954,8 +1043,11 @@ def create_session( rows: int = 24, client: str = "", encoding: str = "utf-8", + keepalive_sec: int | None = None, post_login_commands: list[str] | None = None, async_connect: bool = True, + username_override: str | None = None, + password_override: str | None = None, ) -> dict[str, Any]: from .cli_resolve import resolve_cli_target @@ -975,6 +1067,19 @@ def create_session( except Exception as exc: raise HTTPException(status_code=400, detail=f"credential_error:{exc}") from exc + # One-shot credentials for SecureCRT-style "do not save password" / retry. + if username_override is not None and str(username_override).strip(): + creds["username"] = str(username_override).strip() + if password_override is not None: + creds["password"] = str(password_override) + + protocol = str(device.get("protocol") or creds.get("protocol") or "ssh").strip().lower() + creds["protocol"] = protocol + # Netmiko telnet drivers dislike a completely missing username; use a placeholder + # for the wire only (interactive login still happens in the terminal). + if protocol == "telnet" and not bool(creds.get("hop_enabled")) and not str(creds.get("username") or "").strip(): + creds["username"] = "telnet" + if not _webcrt_creds_ready(creds): raise HTTPException(status_code=400, detail="credentials_incomplete") @@ -985,11 +1090,14 @@ def create_session( target_id = str(device.get("id") or mid or uid) target_ip = str(device.get("ip_address") or "") target_name = str(device.get("name") or target_ip) - protocol = str(device.get("protocol") or creds.get("protocol") or "ssh") device_type = str(device.get("device_type") or creds.get("device_type") or "") vendor = str(device.get("vendor") or creds.get("vendor") or "") cli_keymap = uses_network_cli_keymap(device_type, vendor) enc = _normalize_encoding(encoding) + if keepalive_sec is None: + ka = max(0, int(getattr(settings, "webcrt_keepalive_sec", 0) or 0)) + else: + ka = max(0, min(600, int(keepalive_sec))) sess = WebcrtSession( session_id=session_id, @@ -1003,6 +1111,7 @@ def create_session( vendor=vendor, cli_keymap=cli_keymap, encoding=enc, + keepalive_sec=ka, state="connecting", post_login_commands=list(post_login_commands or [])[:20], ) @@ -1057,6 +1166,7 @@ def create_session( "cols": sess.cols, "rows": sess.rows, "encoding": enc, + "keepalive_sec": ka, "state": sess.state, "ws_path": f"/v1/webcrt/sessions/{session_id}/ws", "cli_hop": bool(sess.cli_hop_guard), @@ -1153,6 +1263,7 @@ def list_sessions() -> dict[str, Any]: "ne_ip": s.ne_ip, "protocol": s.protocol, "encoding": s.encoding, + "keepalive_sec": int(s.keepalive_sec or 0), "state": s.state, "attached": s.attached, "created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(), @@ -1174,7 +1285,7 @@ def list_sessions() -> dict[str, Any]: "total": len(items), "max_sessions": max(1, int(settings.webcrt_max_sessions or 20)), "idle_timeout_sec": max(60, int(settings.webcrt_idle_timeout_sec or 1800)), - "keepalive_sec": int(getattr(settings, "webcrt_keepalive_sec", 30) or 0), + "keepalive_sec": int(getattr(settings, "webcrt_keepalive_sec", 0) or 0), "anti_idle_sec": int(getattr(settings, "webcrt_anti_idle_sec", 0) or 0), "items": items, } diff --git a/tests/test_managed_ne.py b/tests/test_managed_ne.py index 93781e2..73c4d0f 100644 --- a/tests/test_managed_ne.py +++ b/tests/test_managed_ne.py @@ -12,10 +12,18 @@ from sqlalchemy.pool import StaticPool from netx_api.config import settings from netx_api.db import Base, get_db from netx_api.main import app -from netx_api.models import ManagedNE, UmeInventoryNE # noqa: F401 — register table on Base +from netx_api.models import CliConnectProfile, ManagedNE, UmeInventoryNE # noqa: F401 — register table on Base from netx_api.ne_connect import hostname_probe_command, parse_hostname_from_output from netx_api.ne_crypto import decrypt_secret, encrypt_secret -from netx_api.ne_service import UME_SYNC_SOURCE, create_managed_ne, import_managed_ne +from netx_api.cli_service import list_cli_targets +from netx_api.device_types import WEBCRT_DEVICE_TYPES, WEBCRT_NE_SOURCE +from netx_api.ne_service import ( + UME_SYNC_SOURCE, + create_managed_ne, + import_managed_ne, + upsert_webcrt_managed_ne, + upsert_webcrt_session_host, +) from netx_api.ne_schemas import ManagedNeCreate @@ -319,5 +327,182 @@ class ManagedNeCreateOptionalPasswordTests(unittest.TestCase): self.assertEqual(row.password_enc, "") +class WebcrtUpsertAndTargetsTests(unittest.TestCase): + def setUp(self): + self._orig = settings.credential_secret_key + settings.credential_secret_key = Fernet.generate_key().decode() + self.engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + ManagedNE.__table__.create(bind=self.engine, checkfirst=True) + UmeInventoryNE.__table__.create(bind=self.engine, checkfirst=True) + CliConnectProfile.__table__.create(bind=self.engine, checkfirst=True) + self.db = sessionmaker(bind=self.engine)() + + def tearDown(self): + self.db.close() + settings.credential_secret_key = self._orig + + def test_webcrt_device_types_include_linux(self): + self.assertIn("linux", WEBCRT_DEVICE_TYPES) + self.assertIn("generic", WEBCRT_DEVICE_TYPES) + self.assertIn("zte_zxros", WEBCRT_DEVICE_TYPES) + + def test_upsert_webcrt_create_update_reuse(self): + created, action = upsert_webcrt_managed_ne( + self.db, + ManagedNeCreate( + name="linux-1", + vendor="Other", + device_type="linux", + ip_address="10.8.8.8", + username="root", + password="secret", + ), + ) + self.assertEqual(action, "created") + row = self.db.query(ManagedNE).filter(ManagedNE.id == created.id).one() + self.assertEqual(row.source, WEBCRT_NE_SOURCE) + self.assertEqual(row.device_type, "linux") + + updated, action2 = upsert_webcrt_managed_ne( + self.db, + ManagedNeCreate( + name="linux-1b", + vendor="Other", + device_type="linux_ssh", + ip_address="10.8.8.8", + username="root", + password="secret2", + ), + ) + self.assertEqual(action2, "updated") + self.assertEqual(updated.id, created.id) + self.assertEqual(updated.name, "linux-1b") + self.assertEqual(updated.device_type, "linux") + + inv = create_managed_ne( + self.db, + ManagedNeCreate( + vendor="ZTE", + device_type="zte_zxros", + ip_address="10.8.8.9", + username="admin", + password="p", + ), + ) + reused, action3 = upsert_webcrt_managed_ne( + self.db, + ManagedNeCreate( + vendor="ZTE", + device_type="zte_zxros", + ip_address="10.8.8.9", + username="admin", + password="ignored", + ), + ) + self.assertEqual(action3, "reused") + self.assertEqual(reused.id, inv.id) + # Inventory row must not be rewritten as webcrt. + keep = self.db.query(ManagedNE).filter(ManagedNE.id == inv.id).one() + self.assertNotEqual(keep.source, WEBCRT_NE_SOURCE) + + def test_list_cli_targets_webcrt_source(self): + upsert_webcrt_managed_ne( + self.db, + ManagedNeCreate( + name="sess-a", + vendor="Other", + device_type="linux", + ip_address="10.7.7.7", + username="u", + password="p", + ), + ) + create_managed_ne( + self.db, + ManagedNeCreate( + vendor="ZTE", + device_type="zte_zxros", + ip_address="10.7.7.8", + username="admin", + password="p", + ), + ) + webcrt = list_cli_targets(self.db, source="webcrt", page=1, page_size=50) + self.assertEqual(webcrt["total"], 1) + self.assertEqual(webcrt["items"][0]["source"], "webcrt") + self.assertEqual(webcrt["items"][0]["ip_address"], "10.7.7.7") + self.assertTrue(webcrt["items"][0]["has_password"]) + + managed = list_cli_targets(self.db, source="managed", page=1, page_size=50) + ips = {x["ip_address"] for x in managed["items"]} + self.assertIn("10.7.7.8", ips) + self.assertNotIn("10.7.7.7", ips) + + def test_upsert_session_host_telnet_no_password(self): + out, action = upsert_webcrt_session_host( + self.db, + name="tn", + ip_address="10.6.6.6", + port=23, + protocol="telnet", + ) + self.assertEqual(action, "created") + row = self.db.query(ManagedNE).filter(ManagedNE.id == out.id).one() + self.assertEqual(row.protocol, "telnet") + self.assertEqual(row.password_enc, "") + self.assertEqual(row.device_type, "generic") + self.assertEqual(row.source, WEBCRT_NE_SOURCE) + + def test_upsert_session_host_ssh_unsaved_password(self): + out, action = upsert_webcrt_session_host( + self.db, + name="ssh1", + ip_address="10.6.6.7", + port=22, + protocol="ssh", + username="root", + password="ephemeral", + save_password=False, + ) + self.assertEqual(action, "created") + row = self.db.query(ManagedNE).filter(ManagedNE.id == out.id).one() + self.assertEqual(row.username, "root") + self.assertEqual(row.password_enc, "") + + out2, action2 = upsert_webcrt_session_host( + self.db, + name="ssh1", + ip_address="10.6.6.7", + protocol="ssh", + username="root", + password="secret", + save_password=True, + ) + self.assertEqual(action2, "created") + self.assertNotEqual(out2.id, out.id) + self.assertEqual(out2.name, "ssh1 (1)") + row2 = self.db.query(ManagedNE).filter(ManagedNE.id == out2.id).one() + self.assertTrue(str(row2.password_enc or "").strip()) + + def test_session_host_same_ip_name_suffix(self): + a, _ = upsert_webcrt_session_host( + self.db, ip_address="10.5.5.5", protocol="ssh", username="u", password="p", save_password=True + ) + b, _ = upsert_webcrt_session_host( + self.db, ip_address="10.5.5.5", protocol="ssh", username="u", password="p", save_password=True + ) + c, _ = upsert_webcrt_session_host( + self.db, ip_address="10.5.5.5", protocol="telnet" + ) + self.assertEqual(a.name, "10.5.5.5") + self.assertEqual(b.name, "10.5.5.5 (1)") + self.assertEqual(c.name, "10.5.5.5 (2)") + self.assertEqual(a.ip_address, b.ip_address) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ne_cli_errors.py b/tests/test_ne_cli_errors.py index 466fd95..cbe7c6e 100644 --- a/tests/test_ne_cli_errors.py +++ b/tests/test_ne_cli_errors.py @@ -33,6 +33,13 @@ class CliAuthClassifyTests(unittest.TestCase): msg = format_cli_failure(AuthenticationException("target_auth_rejected: Permission denied")) self.assertTrue(msg.startswith("auth_rejected:")) + def test_authentication_exception_empty_message(self): + class AuthenticationException(Exception): + pass + + msg = format_cli_failure(AuthenticationException()) + self.assertTrue(msg.startswith("auth_rejected:")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py index 3f5dec8..27f35dd 100644 --- a/tests/test_webcrt.py +++ b/tests/test_webcrt.py @@ -101,6 +101,119 @@ class WebcrtServiceTests(unittest.TestCase): self.assertEqual(svc.prepare_bootstrap_output("banner\nR2#\n\nR2#"), "banner\nR2#") self.assertTrue(svc._is_prompt_only_echo("\r\nR2#\r\n", "R2#")) self.assertFalse(svc._is_prompt_only_echo("R2#show clock\r\n", "R2#")) + self.assertTrue(svc._looks_like_login_prompt("Username:")) + self.assertTrue(svc._looks_like_login_prompt("login:\nPassword:")) + self.assertFalse(svc._looks_like_login_prompt("")) + self.assertTrue(svc._looks_like_cli_prompt("")) + # Stray ':' after Huawei prompt must still count as prompted (no extra Enter). + self.assertTrue(svc._looks_like_cli_prompt(":")) + self.assertEqual(svc.prepare_bootstrap_output("banner\n:"), "banner\n") + self.assertTrue(svc._looks_like_password_change_prompt("Change now? [Y/N]:")) + self.assertFalse(svc._looks_like_password_change_prompt("Change now? [Y/N]:N")) + # WS attach must not send Enter when bootstrap is a login prompt. + self.assertFalse( + (not svc._looks_like_cli_prompt("Username:") and not svc._looks_like_login_prompt("Username:")) + ) + self.assertTrue( + (not svc._looks_like_cli_prompt("") and not svc._looks_like_login_prompt("")) + ) + + def test_capture_raw_channel_keeps_banner(self) -> None: + conn = _FakeConn() + conn.remote_conn.recv_ready.side_effect = [True, True, False, False, False, False] + conn.remote_conn.recv.side_effect = [b"*** IOSv BANNER ***\r\n", b"R2#"] + text = svc._capture_raw_channel(conn, duration=0.2) + self.assertIn("IOSv BANNER", text) + self.assertIn("R2#", text) + self.assertNotIn("MagicMock", text) + + @patch.object(svc, "_audit") + @patch.object(svc, "open_netmiko_connection") + @patch("netx_api.cli_resolve.resolve_cli_target") + def test_bootstrap_from_channel_when_session_log_empty( + self, + mock_resolve: MagicMock, + mock_open: MagicMock, + _mock_audit: MagicMock, + ) -> None: + """Interactive generic SSH: banner is on the PTY, not in Netmiko session_log.""" + mock_resolve.return_value = ( + {"username": "admin", "password": "x", "protocol": "ssh", "ip_address": "192.168.0.128"}, + { + "id": "ne-banner", + "name": "R2", + "ip_address": "192.168.0.128", + "protocol": "ssh", + "device_type": "generic", + "source": "webcrt", + }, + ) + fake = _FakeConn() + # Already at prompt with banner waiting on the channel (no session_log writes). + fake.remote_conn.recv_ready.side_effect = [True, True, False] * 20 + fake.remote_conn.recv.side_effect = [ + b"**************************************************************************\r\n", + b"R2#", + ] + [b""] * 40 + + def _open(*_a, **_k): + return fake + + mock_open.side_effect = _open + out = svc.create_session( + MagicMock(), ne_id="ne-banner", cols=80, rows=24, client="test", async_connect=False + ) + sess = svc.get_session(out["session_id"]) + assert sess is not None + boot = sess.bootstrap_output.decode("utf-8", errors="replace") + self.assertIn("****", boot) + self.assertIn("R2#", boot) + svc.close_session(out["session_id"], reason="test") + + @patch.object(svc, "_audit") + @patch.object(svc, "open_netmiko_connection") + @patch("netx_api.cli_resolve.resolve_cli_target") + def test_create_session_password_override( + self, + mock_resolve: MagicMock, + mock_open: MagicMock, + _mock_audit: MagicMock, + ) -> None: + mock_resolve.return_value = ( + { + "username": "u", + "password": "", + "hop_enabled": False, + "ip_address": "10.0.0.9", + "protocol": "ssh", + "device_type": "linux", + "port": 22, + }, + { + "id": "ne-ephemeral", + "name": "E", + "ip_address": "10.0.0.9", + "protocol": "ssh", + "source": "webcrt", + "device_type": "linux", + }, + ) + mock_open.side_effect = lambda *a, **k: _FakeConn() + db = MagicMock() + with self.assertRaises(HTTPException) as ctx: + svc.create_session(db, ne_id="ne-ephemeral", async_connect=False) + self.assertEqual(ctx.exception.status_code, 400) + out = svc.create_session( + db, + ne_id="ne-ephemeral", + async_connect=False, + username_override="u", + password_override="once", + ) + self.assertEqual(out.get("state"), "ready") + called_creds = mock_open.call_args.args[0] if mock_open.call_args.args else mock_open.call_args[0][0] + self.assertEqual(called_creds.get("password"), "once") + svc.close_session(out["session_id"], reason="test") @patch.object(svc, "_audit") @patch.object(svc, "open_netmiko_connection") @@ -190,6 +303,8 @@ class WebcrtServiceTests(unittest.TestCase): self.assertTrue(called_creds["hop_enabled"]) self.assertEqual(called_creds["hop_vendor"], "bastion") self.assertIn("session_log", mock_open.call_args.kwargs) + self.assertEqual(mock_open.call_args.kwargs.get("keepalive"), 0) + self.assertEqual(out.get("keepalive_sec"), 0) fake.remote_conn.resize_pty.assert_called() self.assertEqual(out["ne_id"], "ne-hop") self.assertFalse(out.get("cli_hop")) # bastion hop is not vendor CLI hop guard @@ -320,6 +435,17 @@ class WebcrtServiceTests(unittest.TestCase): { "username": "u", "password": "", + "protocol": "ssh", + "hop_enabled": False, + } + ) + ) + self.assertTrue( + svc._webcrt_creds_ready( + { + "username": "", + "password": "", + "protocol": "telnet", "hop_enabled": False, } ) @@ -495,6 +621,17 @@ class WebcrtServiceTests(unittest.TestCase): self.assertEqual(svc._normalize_encoding("utf8"), "utf-8") self.assertEqual(svc._encode_text("测", "gbk")[:1], b"\xb2") + def test_linux_telnet_maps_to_generic_telnet(self) -> None: + from netx_api.ne_netmiko import normalize_netmiko_device_type + from netx_api.ne_session_factory import _netmiko_driver_class + + dt = normalize_netmiko_device_type("linux", "telnet") + self.assertEqual(dt, "generic_telnet") + self.assertIsNotNone(_netmiko_driver_class(dt)) + self.assertEqual(normalize_netmiko_device_type("linux", "ssh"), "linux_ssh") + self.assertEqual(normalize_netmiko_device_type("generic", "ssh"), "generic_termserver_ssh") + self.assertIsNotNone(_netmiko_driver_class("generic_termserver_ssh")) + if __name__ == "__main__": unittest.main() diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index 34572e7..364308b 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -2,6 +2,10 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "re import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; +import { + applyKeywordHighlight, + type KeywordHighlightConfig, +} from "../utils/webcrtKeywordHighlight"; export type WebTerminalHandle = { clear: () => void; @@ -16,15 +20,55 @@ export type WebTerminalHandle = { findPrevious: (term: string) => void; }; +export type TermColors = { + background: string; + foreground: string; +}; + +const DEFAULT_TERM_COLORS: TermColors = { + background: "#0b1220", + foreground: "#e2e8f0", +}; + +function normalizeHex(hex: string, fallback: string): string { + const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex || "").trim()); + return m ? `#${m[1].toLowerCase()}` : fallback; +} + +function hexLuminance(hex: string): number { + const n = normalizeHex(hex, "#000000").slice(1); + const r = parseInt(n.slice(0, 2), 16) / 255; + const g = parseInt(n.slice(2, 4), 16) / 255; + const b = parseInt(n.slice(4, 6), 16) / 255; + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function xtermThemeFromColors(colors: TermColors) { + const background = normalizeHex(colors.background, DEFAULT_TERM_COLORS.background); + const foreground = normalizeHex(colors.foreground, DEFAULT_TERM_COLORS.foreground); + return { + background, + foreground, + cursor: foreground, + selectionBackground: hexLuminance(background) < 0.5 ? "#334155" : "#93c5fd", + }; +} + type Props = { wsUrl: string; title?: string; recording?: boolean; autoFocus?: boolean; encoding?: string; + fontSize?: number; + /** Terminal background / foreground (overrides legacy themeName). */ + termColors?: TermColors; + /** @deprecated use termColors */ + themeName?: "dark" | "light"; pasteDelayMs?: number; copyOnSelect?: boolean; - onStatus?: (state: string, message?: string) => void; + keywordHighlight?: KeywordHighlightConfig; + onStatus?: (state: string, message?: string, phase?: string) => void; onReady?: () => void; onStdout?: (data: string) => void; }; @@ -103,14 +147,22 @@ export const WebTerminal = forwardRef(function WebTerm recording, autoFocus = true, encoding = "utf-8", + fontSize = 13, + termColors, + themeName = "dark", pasteDelayMs, copyOnSelect, + keywordHighlight, onStatus, onReady, onStdout, }, ref, ) { + const resolvedColors: TermColors = termColors || + (themeName === "light" + ? { background: "#ffffff", foreground: "#000000" } + : DEFAULT_TERM_COLORS); const hostRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); @@ -122,9 +174,14 @@ export const WebTerminal = forwardRef(function WebTerm const recordingRef = useRef(!!recording); const autoFocusRef = useRef(autoFocus); const encodingRef = useRef(encoding); + const fontSizeRef = useRef(Math.max(10, Math.min(28, Number(fontSize) || 13))); + const termColorsRef = useRef(resolvedColors); const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs); const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect); const pasteQueueRef = useRef>(Promise.resolve()); + /** Updated whenever device stdout arrives; used to pace paste by echo, not fixed sleep. */ + const lastStdoutAtRef = useRef(0); + const keywordHighlightRef = useRef(keywordHighlight); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); const [findOpen, setFindOpen] = useState(false); const [findQuery, setFindQuery] = useState(""); @@ -149,6 +206,25 @@ export const WebTerminal = forwardRef(function WebTerm encodingRef.current = encoding; }, [encoding]); + useEffect(() => { + fontSizeRef.current = Math.max(10, Math.min(28, Number(fontSize) || 13)); + const term = termRef.current; + if (!term) return; + term.options.fontSize = fontSizeRef.current; + try { + fitRef.current?.fit(); + } catch { + /* ignore */ + } + }, [fontSize]); + + useEffect(() => { + termColorsRef.current = resolvedColors; + const term = termRef.current; + if (!term) return; + term.options.theme = xtermThemeFromColors(resolvedColors); + }, [resolvedColors.background, resolvedColors.foreground]); + useEffect(() => { if (pasteDelayMs != null) pasteDelayRef.current = pasteDelayMs; }, [pasteDelayMs]); @@ -157,6 +233,8 @@ export const WebTerminal = forwardRef(function WebTerm if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect; }, [copyOnSelect]); + keywordHighlightRef.current = keywordHighlight; + const focusTerminal = () => { try { termRef.current?.focus(); @@ -177,11 +255,37 @@ export const WebTerminal = forwardRef(function WebTerm sendJson({ type: "stdin", data }); }; - /** SecureCRT-like paste: send line-by-line with delay; show progress while sending. */ + /** Wait until device echoes (stdout after sentAt) or maxMs elapses — whichever first. */ + const waitForEchoOrTimeout = (maxMs: number, sentAt: number) => + new Promise((resolve) => { + if (maxMs <= 0) { + resolve(); + return; + } + const started = performance.now(); + const tick = () => { + if (lastStdoutAtRef.current >= sentAt) { + // Brief settle so multi-chunk echoes finish before the next line. + window.setTimeout(resolve, 2); + return; + } + if (performance.now() - started >= maxMs) { + resolve(); + return; + } + window.setTimeout(tick, 2); + }; + tick(); + }); + + /** + * Line-by-line paste paced by device echo. + * pasteDelayMs is a *maximum* wait per line; fast responses advance immediately. + */ const sendStdinThrottled = (data: string) => { if (!data) return; - const delay = pasteDelayRef.current; - if (delay <= 0 || data.length < 8) { + const maxDelay = pasteDelayRef.current; + if (maxDelay <= 0 || data.length < 8) { sendStdinImmediate(data); return; } @@ -193,10 +297,11 @@ export const WebTerminal = forwardRef(function WebTerm for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; const chunk = i < lines.length - 1 ? `${line}\r` : line; + const sentAt = performance.now(); if (chunk) sendStdinImmediate(chunk); setPasteStatus({ done: i + 1, total }); if (i < lines.length - 1) { - await new Promise((r) => window.setTimeout(r, delay)); + await waitForEchoOrTimeout(maxDelay, sentAt); } } } finally { @@ -274,15 +379,10 @@ export const WebTerminal = forwardRef(function WebTerm const term = new Terminal({ cursorBlink: true, - fontSize: 13, + fontSize: fontSizeRef.current, fontFamily: 'Consolas, "Courier New", monospace', scrollback: 10000, - theme: { - background: "#0b1220", - foreground: "#e2e8f0", - cursor: "#e2e8f0", - selectionBackground: "#334155", - }, + theme: xtermThemeFromColors(termColorsRef.current), convertEol: true, }); const fit = new FitAddon(); @@ -312,22 +412,31 @@ export const WebTerminal = forwardRef(function WebTerm }, 50); }); + // Guard against React StrictMode remount: the first WS teardown must not + // report closed/error after a newer socket owns the terminal. + let cancelled = false; const ws = new WebSocket(wsUrl); ws.binaryType = "arraybuffer"; wsRef.current = ws; onStatusRef.current?.("connecting"); + const isActiveSocket = () => !cancelled && wsRef.current === ws; + const writeStdout = (raw: string) => { - if (raw) term.write(raw); + if (!raw || !isActiveSocket()) return; + lastStdoutAtRef.current = performance.now(); if (recordingRef.current) onStdoutRef.current?.(raw); + term.write(applyKeywordHighlight(raw, keywordHighlightRef.current)); }; const sendResize = () => { + if (!isActiveSocket()) return; doFit(); sendJson({ type: "resize", cols: term.cols, rows: term.rows }); }; ws.onopen = () => { + if (!isActiveSocket()) return; onStatusRef.current?.("open"); sendResize(); onReadyRef.current?.(); @@ -335,6 +444,7 @@ export const WebTerminal = forwardRef(function WebTerm }; ws.onmessage = (ev) => { + if (!isActiveSocket()) return; if (ev.data instanceof ArrayBuffer) { writeStdout(decodeBytes(ev.data, encodingRef.current)); maybeFocus(); @@ -342,6 +452,7 @@ export const WebTerminal = forwardRef(function WebTerm } if (typeof Blob !== "undefined" && ev.data instanceof Blob) { void ev.data.arrayBuffer().then((buf) => { + if (!isActiveSocket()) return; writeStdout(decodeBytes(buf, encodingRef.current)); maybeFocus(); }); @@ -353,6 +464,7 @@ export const WebTerminal = forwardRef(function WebTerm data?: string; state?: string; message?: string; + phase?: string; }; if (msg.type === "stdout" && typeof msg.data === "string") { writeStdout(msg.data); @@ -360,7 +472,9 @@ export const WebTerminal = forwardRef(function WebTerm return; } if (msg.type === "status") { - onStatusRef.current?.(String(msg.state || ""), msg.message); + if (!isActiveSocket()) return; + const phase = typeof msg.phase === "string" ? msg.phase : undefined; + onStatusRef.current?.(String(msg.state || ""), msg.message, phase); if (msg.state === "connected" || msg.state === "connecting") { maybeFocus(); return; @@ -378,11 +492,14 @@ export const WebTerminal = forwardRef(function WebTerm }; ws.onerror = () => { + if (!isActiveSocket()) return; onStatusRef.current?.("error", "websocket_error"); term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m"); }; ws.onclose = (ev) => { + // Intentional unmount/remount closes the socket; do not flip UI to "closed". + if (!isActiveSocket()) return; onStatusRef.current?.("closed", `websocket_closed:${ev.code}`); if (!ev.wasClean) { term.writeln(`\r\n\x1b[33m[websocket closed code=${ev.code}]\x1b[0m`); @@ -475,6 +592,7 @@ export const WebTerminal = forwardRef(function WebTerm ro?.observe(host); return () => { + cancelled = true; window.clearInterval(pingTimer); window.removeEventListener("resize", onWinResize); window.removeEventListener("keydown", onKeyDownCapture, true); @@ -483,17 +601,18 @@ export const WebTerminal = forwardRef(function WebTerm ro?.disconnect(); dataDisposable.dispose(); selDisposable.dispose(); + if (wsRef.current === ws) wsRef.current = null; try { ws.close(); } catch { /* ignore */ } - wsRef.current = null; term.dispose(); termRef.current = null; fitRef.current = null; }; - }, [wsUrl, title]); + // title is display-only; remounting on title change tears down a live WS. + }, [wsUrl]); const pasteFromClipboard = async () => { try { diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 296fd23..7c4bfa7 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -905,7 +905,7 @@ const en = { sessionManager: "Device list", connect: "Connect", 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.", + hintCrt: "Click an NE on the left to open a terminal. Includes managed, Sessions, and UME; or use New Session for a quick login.", filterKeyword: "Filter", filterKeywordPh: "Filter by name / IP / tag", empty: "No matching NEs. Switch source or refine the search.", @@ -919,8 +919,25 @@ const en = { source: { all: "All", managed: "Managed", + webcrt: "Sessions", ume: "UME", }, + newSession: { + title: "New Session", + hint: "Enter host and protocol. SSH prompts for username/password (editable on retry); Telnet logs in interactively. Duplicate names become (1), (2)…; same IP is allowed.", + connect: "Connect", + connecting: "Connecting…", + protocol: "Protocol", + host: "Hostname", + port: "Port", + sessionName: "Session name", + sessionNamePh: "Optional; defaults to IP", + authTitle: "SSH Authentication", + savePassword: "Save password", + ipRequired: "Hostname is required", + userRequired: "Username is required", + passwordRequired: "Password is required", + }, status: { idle: "Idle", connecting: "Connecting", @@ -928,6 +945,47 @@ const en = { error: "Error", closed: "Closed", }, + phase: { + creating: "Creating session", + authenticating: "Authenticating", + waitingPrompt: "Waiting for device prompt", + hint: "Slow devices may take more than 10 seconds", + }, + tabMenu: { + close: "Close", + closeOthers: "Close others", + closeAll: "Close all", + copyIp: "Copy IP", + copyIpOk: "Copied {{ip}}", + copyIpEmpty: "No IP on this tab", + }, + treeMenu: { + rename: "Rename", + renameLabel: "Session name", + renameSave: "Save", + renameRequired: "Session name is required", + renamed: "Session renamed", + delete: "Delete session", + deleteConfirm: "Delete session \"{{name}}\"? This cannot be undone.", + deleted: "Session deleted", + }, + keywordHl: { + title: "Keyword Highlighting", + enabled: "Enabled", + newKeyword: "New keyword", + newKeywordPh: "Keyword or regex", + regex: "Regular expression", + add: "Add", + colKeyword: "Keyword", + colRegex: "Regex", + empty: "No keywords yet", + moveUp: "Move up", + moveDown: "Move down", + remove: "Remove", + caseSensitive: "Case sensitive", + color: "Text color", + saved: "Keyword highlighting saved", + }, actions: { reconnect: "Reconnect", recordLog: "Record log", @@ -949,7 +1007,34 @@ const en = { recent: "Recent", favorite: "Toggle favorite", encoding: "Encoding", - fontSize: "Font", + fontSize: "Font size", + theme: "Theme", + themeDark: "Dark", + themeLight: "Light", + colorScheme: "Color scheme", + backgroundColor: "Background", + foregroundColor: "Font color", + scheme: { + dark: "Deep blue (default)", + blackWhite: "Black on white", + whiteBlack: "White on black", + greenBlack: "Green on black", + amberBlack: "Amber on black", + custom: "Custom", + }, + copyOnSelect: "Copy on select", + pasteDelay: "Paste max line wait", + pasteDelayOff: "No delay (bulk send)", + keepalive: "SSH keepalive", + keepaliveOff: "Off", + keepaliveSec: "{{n}} sec", + options: "Options", + globalSessionOptions: "Global Session Options", + sessionOptionsSave: "Save", + sessionOptionsCancel: "Cancel", + sessionOptionsSaved: "Global session options saved", + disconnectBanner: "Session disconnected", + disconnectBannerError: "Session error or connection lost", broadcastPh: "Send to all tabs…", buttonBarHint: "One per line: label|command", postLoginPrompt: "Post-login commands (one per line, this device)", @@ -960,9 +1045,12 @@ const en = { uploaded: "Upload complete", }, err: { + deviceType: "Unsupported device type", + ipConflictRestart: "Database still enforces unique IP; restart the netx API and retry", sessionLimit: "Session limit reached; close other terminals first", neNotFound: "Network element not found", credsIncomplete: "Incomplete username/password on this NE", + authRejected: "Wrong username or password. Please try again.", cliProfile: "UME NEs require a CLI connect profile (default account / hop)", connectFailed: "Device login failed: {{detail}}", sftpHop: "SFTP does not support hop devices yet (direct SSH only)", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 3f3616a..4a4bc8b 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -902,7 +902,7 @@ const zh = { sessionManager: "设备列表", connect: "连接", hint: "选择网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。", - hintCrt: "在左侧点击网元即可打开终端;支持托管与 UME 网元,可用搜索与分页浏览。", + hintCrt: "在左侧点击网元即可打开终端;支持托管、会话与 UME;也可用菜单「新建会话」快速登录。", filterKeyword: "筛选", filterKeywordPh: "按名称 / IP / 标签筛选", empty: "没有匹配的网元。可切换来源或调整搜索关键字。", @@ -916,8 +916,25 @@ const zh = { source: { all: "全部", managed: "托管", + webcrt: "会话", ume: "UME", }, + newSession: { + title: "新建会话", + hint: "填写主机与协议后连接。SSH 弹出用户名/密码(可改);Telnet 进终端交互登录。同名会话自动加 (1)、(2)…,允许相同 IP。", + connect: "连接", + connecting: "连接中…", + protocol: "协议", + host: "主机", + port: "端口", + sessionName: "会话名", + sessionNamePh: "可选,默认使用 IP", + authTitle: "SSH 登录", + savePassword: "保存密码", + ipRequired: "请填写主机地址", + userRequired: "请填写用户名", + passwordRequired: "请填写密码", + }, status: { idle: "空闲", connecting: "连接中", @@ -925,6 +942,47 @@ const zh = { error: "错误", closed: "已关闭", }, + phase: { + creating: "创建会话", + authenticating: "正在认证", + waitingPrompt: "等待设备提示符", + hint: "设备较慢时可能需要十几秒,请稍候", + }, + tabMenu: { + close: "关闭", + closeOthers: "关闭其他", + closeAll: "关闭全部", + copyIp: "复制 IP", + copyIpOk: "已复制 {{ip}}", + copyIpEmpty: "该标签没有 IP", + }, + treeMenu: { + rename: "重命名", + renameLabel: "会话名", + renameSave: "保存", + renameRequired: "请填写会话名", + renamed: "会话已重命名", + delete: "删除会话", + deleteConfirm: "确定删除会话「{{name}}」?此操作不可恢复。", + deleted: "会话已删除", + }, + keywordHl: { + title: "关键字突出显示", + enabled: "启用", + newKeyword: "新建关键字", + newKeywordPh: "输入关键字或正则", + regex: "正则表达式", + add: "添加", + colKeyword: "关键字", + colRegex: "正则", + empty: "尚未添加关键字", + moveUp: "上移", + moveDown: "下移", + remove: "删除", + caseSensitive: "区分大小写", + color: "字符颜色", + saved: "关键字突出显示已保存", + }, actions: { reconnect: "重新连接", recordLog: "记录日志", @@ -947,6 +1005,33 @@ const zh = { favorite: "收藏/取消", encoding: "编码", fontSize: "字号", + theme: "主题", + themeDark: "深色", + themeLight: "浅色", + colorScheme: "配色方案", + backgroundColor: "背景色", + foregroundColor: "字体色", + scheme: { + dark: "深蓝(默认)", + blackWhite: "白底黑字", + whiteBlack: "黑底白字", + greenBlack: "黑底绿字", + amberBlack: "黑底琥珀", + custom: "自定义", + }, + copyOnSelect: "选中即复制", + pasteDelay: "粘贴最大行间隔", + pasteDelayOff: "无延迟(整块发送)", + keepalive: "SSH Keepalive", + keepaliveOff: "关闭", + keepaliveSec: "{{n}} 秒", + options: "选项", + globalSessionOptions: "全局会话选项", + sessionOptionsSave: "保存", + sessionOptionsCancel: "取消", + sessionOptionsSaved: "已保存全局会话选项", + disconnectBanner: "会话已断开", + disconnectBannerError: "会话出错或连接中断", broadcastPh: "发送到全部标签…", buttonBarHint: "每行:按钮名|命令", postLoginPrompt: "登录后自动执行(每行一条,仅本设备)", @@ -957,9 +1042,12 @@ const zh = { uploaded: "上传成功", }, err: { + deviceType: "不支持的设备类型", + ipConflictRestart: "数据库仍限制 IP 唯一,请重启 netx API 后再试", sessionLimit: "并发会话已达上限,请先关闭其他终端", neNotFound: "网元不存在", credsIncomplete: "网元账号或密码不完整", + authRejected: "用户名或密码错误,请重新输入", cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)", connectFailed: "登录设备失败:{{detail}}", sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH", diff --git a/web/src/index.css b/web/src/index.css index 3e6d0d9..94dd359 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1883,20 +1883,354 @@ pre { letter-spacing: -0.01em; } +.webcrt-sidebar__chrome { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 10px; + background: linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%); + border-bottom: 1px solid #d7dee8; + flex-shrink: 0; +} + +.webcrt-sidebar__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; + user-select: none; +} + +.webcrt-sidebar__new-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + padding: 0 10px; + border: 1px solid #b8c4d2; + border-radius: 6px; + background: #fff; + color: var(--nm-brand); + font-size: 12px; + font-weight: 650; + line-height: 1; + cursor: pointer; + box-shadow: none; +} + +.webcrt-sidebar__new-btn:hover { + background: #e8f0fe; + border-color: #9eb0c6; + color: var(--nm-accent); +} + +.app-main .webcrt-sidebar__new-btn { + border: 1px solid #b8c4d2; + box-shadow: none; + height: 28px; + border-radius: 6px; +} + +.webcrt-menubar__item { + position: relative; + flex-shrink: 0; +} + +.webcrt-menubar__btn { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: #475569; + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: pointer; + box-shadow: none; +} + +.webcrt-menubar__btn:hover, +.webcrt-menubar__btn.is-open { + background: #dbe4f0; + color: var(--nm-brand); +} + +.app-main .webcrt-menubar__btn { + border: 0; + box-shadow: none; + height: 28px; + border-radius: 6px; +} + +.webcrt-menubar__menu { + position: absolute; + top: calc(100% + 2px); + left: 0; + z-index: 40; + min-width: 180px; + padding: 4px 0; + background: #fff; + border: 1px solid #c5d0de; + border-radius: 4px; + box-shadow: 0 8px 20px rgba(15, 39, 68, 0.14); +} + +.webcrt-menubar__menu--end { + left: auto; + right: 0; +} + +.webcrt-menubar__menu-item { + display: block; + width: 100%; + padding: 7px 14px; + border: 0; + background: transparent; + color: #1e293b; + font-size: 12px; + text-align: left; + cursor: pointer; + box-shadow: none; + border-radius: 0; +} + +.webcrt-menubar__menu-item:hover { + background: #e8f0fe; + color: var(--nm-brand); +} + +.app-main .webcrt-menubar__menu-item { + border: 0; + box-shadow: none; + border-radius: 0; + height: auto; +} + +.webcrt-session-opts { + display: grid; + gap: 14px; +} + +.webcrt-session-opts__field { + display: grid; + gap: 6px; + font-size: 13px; + color: #334155; + font-weight: 600; +} + +.webcrt-session-opts__field--check { + grid-template-columns: 1fr auto; + align-items: center; + gap: 12px; +} + +.webcrt-session-opts__field--check input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; +} + +.webcrt-session-opts__field select { + height: 34px; + padding: 0 10px; + border: 1px solid #b8c4d2; + border-radius: 6px; + background: #fff; + font-size: 13px; + font-weight: 500; + color: #0f172a; +} + +.webcrt-session-opts__hint { + margin: 12px 0 0; +} + +.webcrt-session-opts__colors { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + align-items: end; +} + +.webcrt-session-opts__color input[type="color"] { + width: 100%; + height: 34px; + padding: 2px; + border: 1px solid #b8c4d2; + border-radius: 6px; + background: #fff; + cursor: pointer; +} + +.webcrt-session-opts__preview { + grid-column: 1 / -1; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #c5d0de; + border-radius: 6px; + font-family: Consolas, "Courier New", monospace; + font-size: 13px; + font-weight: 600; +} + +.webcrt-session-opts-modal { + width: min(460px, 100%); +} + +.webcrt-keyword-modal { + width: min(520px, 100%); +} + +.webcrt-keyword-add { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 8px; + align-items: end; + margin: 12px 0; +} + +.webcrt-keyword-add__regex { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #334155; + padding-bottom: 8px; + white-space: nowrap; +} + +.webcrt-keyword-list-wrap { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto 1fr; + gap: 6px 8px; + margin-top: 4px; +} + +.webcrt-keyword-list-head { + grid-column: 1; + display: grid; + grid-template-columns: 1fr 48px; + gap: 8px; + padding: 0 8px; + font-size: 11px; + font-weight: 650; + color: #64748b; +} + +.webcrt-keyword-list { + grid-column: 1; + list-style: none; + margin: 0; + padding: 0; + min-height: 160px; + max-height: 220px; + overflow: auto; + border: 1px solid #c5d0de; + border-radius: 6px; + background: #fff; +} + +.webcrt-keyword-list__empty { + padding: 16px; + font-size: 12px; + color: #94a3b8; + text-align: center; +} + +.webcrt-keyword-list__row { + display: grid; + grid-template-columns: 1fr 48px; + gap: 8px; + width: 100%; + padding: 7px 8px; + border: 0; + border-bottom: 1px solid #eef2f7; + background: transparent; + color: #0f172a; + font-size: 12px; + text-align: left; + cursor: pointer; + border-radius: 0; + box-shadow: none; +} + +.webcrt-keyword-list__row:hover { + background: #f1f5f9; +} + +.webcrt-keyword-list__row.is-selected { + background: #e8f0fe; + color: var(--nm-brand); +} + +.app-main .webcrt-keyword-list__row { + border-radius: 0; + box-shadow: none; + height: auto; +} + +.webcrt-keyword-list__pattern { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.webcrt-keyword-list-actions { + grid-column: 2; + grid-row: 2; + display: flex; + flex-direction: column; + gap: 4px; +} + +.webcrt-keyword-list-actions button { + width: 32px; + height: 28px; + padding: 0; + font-size: 14px; + line-height: 1; +} + +.webcrt-keyword-footer { + display: grid; + grid-template-columns: 1fr auto; + gap: 16px; + align-items: end; + margin-top: 14px; +} + +.webcrt-keyword-color input[type="color"] { + width: 48px; + height: 32px; + padding: 2px; + border: 1px solid #b8c4d2; + border-radius: 6px; + background: #fff; + cursor: pointer; +} + .webcrt-sidebar__toolbar { display: none; } -.webcrt-sidebar__search { - padding: 8px 10px; - border-bottom: 1px solid #d7dee8; +.webcrt-sidebar__chrome .webcrt-sidebar__search { + padding: 0; + border-bottom: 0; flex-shrink: 0; } .webcrt-sidebar__search input { width: 100%; box-sizing: border-box; - height: 32px; + height: 30px; padding: 0 10px; border: 1px solid #b8c4d2; border-radius: 6px; @@ -1904,11 +2238,15 @@ pre { font-size: 12px; } +.webcrt-sidebar__chrome .webcrt-source-tabs { + margin: 0; +} + .webcrt-source-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px; - margin: 0 10px 8px; + margin: 0; padding: 2px; background: #d7dee8; border: 1px solid var(--nm-nav-border); @@ -1916,21 +2254,41 @@ pre { flex-shrink: 0; } +.webcrt-source-tabs--4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.webcrt-new-session-modal, +.webcrt-auth-modal { + width: min(420px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + overflow: auto; +} + +.webcrt-auth-error { + color: #b91c1c; + margin-top: -4px; +} + .webcrt-source-tabs__btn { display: inline-flex; align-items: center; justify-content: center; - height: 28px; - padding: 0 6px; + min-width: 0; + height: 26px; + padding: 0 2px; border: 0; border-radius: 4px; background: transparent; color: var(--nm-muted); - font-size: 12px; + font-size: 11px; font-weight: 600; line-height: 1; cursor: pointer; box-shadow: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .app-main .webcrt-source-tabs__btn, @@ -1996,7 +2354,7 @@ pre { background: transparent; min-height: 0; overscroll-behavior: contain; - padding: 0 8px 8px; + padding: 8px 10px; } .webcrt-tree__folder { @@ -2202,6 +2560,20 @@ pre { box-shadow: 0 1px 0 #fff; } +.webcrt-tabs__item.is-dead { + color: #b91c1c; +} + +.webcrt-tabs__item.is-dead.is-active { + background: #fef2f2; + border-color: #fecaca; + color: #b91c1c; +} + +.webcrt-tabs__item.is-connecting { + color: #b45309; +} + /* span close mark — avoid global `button` border/padding chrome */ .webcrt-tabs__close { display: inline; @@ -2271,6 +2643,83 @@ pre { color: var(--nm-accent); } +.webcrt-action-btn.is-warn { + background: #fef2f2; + border-color: #fca5a5; + color: #b91c1c; +} + +.webcrt-action-btn.is-warn:hover { + background: #fee2e2; + border-color: #f87171; +} + +.webcrt-connect-banner { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + padding: 8px 12px; + background: #1e3a5f; + border-bottom: 1px solid #274a73; + color: #dbeafe; + z-index: 2; +} + +.webcrt-connect-banner__text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: 12px; +} + +.webcrt-connect-banner__text strong { + font-size: 13px; +} + +.webcrt-connect-banner__text span { + opacity: 0.85; +} + +.webcrt-disconnect-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; + padding: 8px 12px; + background: #7f1d1d; + border-bottom: 1px solid #991b1b; + color: #fee2e2; + z-index: 2; +} + +.webcrt-disconnect-banner__text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: 12px; +} + +.webcrt-disconnect-banner__text strong { + font-size: 13px; +} + +.webcrt-disconnect-banner__text span { + opacity: 0.85; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.webcrt-disconnect-banner .webcrt-action-btn.is-warn { + background: #fff; + border-color: #fecaca; + flex-shrink: 0; +} + .webcrt-main__body { flex: 1; min-height: 0; @@ -2290,13 +2739,33 @@ pre { } .webcrt-main__pane .webcrt-term, -.webcrt-main__pane .webcrt-term-wrap { +.webcrt-main__pane .webcrt-term-wrap, +.webcrt-main__pane .webcrt-ssh-login { flex: 1; width: 100%; height: auto !important; min-height: 240px !important; } +.webcrt-ssh-login { + display: flex; + flex-direction: column; + min-height: 0; +} + +.webcrt-ssh-login__save { + flex: 0 0 auto; + padding: 6px 12px; + border-bottom: 1px solid rgba(148, 163, 184, 0.25); + color: #cbd5e1; + background: #0b1220; +} + +.webcrt-ssh-login .ssh-login-term { + flex: 1; + min-height: 200px !important; +} + .webcrt-main__empty, .webcrt-main__placeholder { height: 100%; @@ -2415,6 +2884,20 @@ pre { background: #334155; } +.webcrt-ctx button:disabled { + opacity: 0.4; + cursor: default; +} + +.webcrt-ctx button:disabled:hover { + background: transparent; +} + +.webcrt-tab-ctx { + border-radius: 6px; + min-width: 140px; +} + .webcrt-tree__row { display: flex; align-items: stretch; @@ -2625,10 +3108,6 @@ pre { .webcrt-main { min-height: 50vh; } - - .webcrt-source-tabs { - margin-bottom: 8px; - } } @media (max-width: 1200px) { diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 16d4ef8..2b4f1bf 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -1,23 +1,102 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import { + ApiRequestError, closeWebcrtSession, createWebcrtSession, + deleteManagedNe, fetchCliTargets, fetchManagedNeById, + quickConnectWebcrtSession, + updateManagedNe, webcrtSftpDownload, webcrtSftpList, webcrtSftpUpload, webcrtWsUrl, } from "../services/api"; import { pageCount } from "../utils/display"; -import type { CliTargetItem } from "../types"; +import type { CliTargetItem, ManagedNeItem } from "../types"; +import { + defaultKeywordHighlightConfig, + loadKeywordHighlightConfig, + newKeywordId, + saveKeywordHighlightConfig, + type KeywordHighlightConfig, + type KeywordRule, +} from "../utils/webcrtKeywordHighlight"; const PAGE_SIZE = 50; +const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions"; +const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs"; +const ENCODING_OPTIONS = ["utf-8", "gbk", "gb2312", "gb18030"] 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 KEEPALIVE_OPTIONS = [0, 15, 30, 60, 120] as const; + +type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom"; + +const COLOR_SCHEMES: Record, { background: string; foreground: string }> = { + dark: { background: "#0b1220", foreground: "#e2e8f0" }, + blackWhite: { background: "#ffffff", foreground: "#000000" }, + whiteBlack: { background: "#000000", foreground: "#ffffff" }, + greenBlack: { background: "#000000", foreground: "#33ff33" }, + amberBlack: { background: "#1a1200", foreground: "#ffb000" }, +}; + +const COLOR_SCHEME_IDS = Object.keys(COLOR_SCHEMES) as Array>; + +type SessionOptions = { + encoding: string; + fontSize: number; + colorScheme: ColorSchemeId; + background: string; + foreground: string; + copyOnSelect: boolean; + pasteDelayMs: number; + keepaliveSec: number; +}; + +function normalizeHexColor(hex: string, fallback: string): string { + const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex || "").trim()); + return m ? `#${m[1].toLowerCase()}` : fallback; +} + +function matchColorScheme(background: string, foreground: string): ColorSchemeId { + const bg = normalizeHexColor(background, ""); + const fg = normalizeHexColor(foreground, ""); + for (const id of COLOR_SCHEME_IDS) { + const preset = COLOR_SCHEMES[id]; + if (preset.background === bg && preset.foreground === fg) return id; + } + return "custom"; +} + +type ConnectPhase = "creating" | "authenticating" | "waiting_prompt"; + +type HostForm = { + name: string; + ip_address: string; + port: number; + protocol: "ssh" | "telnet"; +}; + +type AuthForm = { + username: string; + password: string; + savePassword: boolean; +}; + +type AuthDialogState = { + mode: "quick" | "retry"; + host: HostForm; + /** When retrying an existing tree target (ne_id known). */ + target?: CliTargetItem; + errorHint?: string; +}; type TermTab = { key: string; @@ -26,10 +105,154 @@ type TermTab = { termEpoch: number; target: CliTargetItem; status: "connecting" | "connected" | "error" | "closed"; + connectPhase?: ConnectPhase; errorMessage?: string; recording: boolean; + encoding: string; }; +type TabMenuState = { key: string; x: number; y: number }; + +type TreeMenuState = { target: CliTargetItem; x: number; y: number }; + +function connectPhaseLabel( + phase: ConnectPhase | undefined, + t: (key: string, vars?: Record) => string, +): string { + if (phase === "waiting_prompt") return t("webcrt.phase.waitingPrompt"); + if (phase === "authenticating") return t("webcrt.phase.authenticating"); + if (phase === "creating") return t("webcrt.phase.creating"); + return t("webcrt.status.connecting"); +} + +function defaultSessionOptions(): SessionOptions { + return { + encoding: "utf-8", + fontSize: 13, + colorScheme: "dark", + background: COLOR_SCHEMES.dark.background, + foreground: COLOR_SCHEMES.dark.foreground, + copyOnSelect: true, + pasteDelayMs: 40, + keepaliveSec: 0, + }; +} + +function loadSessionOptions(): SessionOptions { + const base = defaultSessionOptions(); + try { + const legacyRaw = localStorage.getItem(LEGACY_TERM_PREFS_KEY); + if (legacyRaw) { + const legacy = JSON.parse(legacyRaw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>; + if (legacy.copyOnSelect === false) base.copyOnSelect = false; + if (legacy.pasteDelayMs != null) { + base.pasteDelayMs = Math.max(0, Math.min(200, Number(legacy.pasteDelayMs) || 40)); + } + } + const raw = localStorage.getItem(SESSION_OPTS_KEY); + if (!raw) return base; + const j = JSON.parse(raw) as Partial & { theme?: string }; + // Migrate legacy theme dark/light. + let background = normalizeHexColor(String(j.background || ""), ""); + let foreground = normalizeHexColor(String(j.foreground || ""), ""); + let colorScheme = String(j.colorScheme || "") as ColorSchemeId; + if (!background || !foreground) { + if (j.theme === "light") { + background = COLOR_SCHEMES.blackWhite.background; + foreground = COLOR_SCHEMES.blackWhite.foreground; + colorScheme = "blackWhite"; + } else { + background = COLOR_SCHEMES.dark.background; + foreground = COLOR_SCHEMES.dark.foreground; + colorScheme = "dark"; + } + } + if (colorScheme !== "custom" && colorScheme in COLOR_SCHEMES) { + const preset = COLOR_SCHEMES[colorScheme as Exclude]; + background = preset.background; + foreground = preset.foreground; + } else { + colorScheme = matchColorScheme(background, foreground); + } + return { + encoding: String(j.encoding || base.encoding).trim().toLowerCase() || "utf-8", + fontSize: Math.max(10, Math.min(28, Number(j.fontSize) || base.fontSize)), + colorScheme, + background, + foreground, + copyOnSelect: j.copyOnSelect !== false, + pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs ?? base.pasteDelayMs) || 40)), + keepaliveSec: Math.max(0, Math.min(600, Number(j.keepaliveSec ?? base.keepaliveSec) || 0)), + }; + } catch { + return base; + } +} + +function saveSessionOptions(opts: SessionOptions) { + localStorage.setItem(SESSION_OPTS_KEY, JSON.stringify(opts)); + localStorage.setItem( + LEGACY_TERM_PREFS_KEY, + JSON.stringify({ copyOnSelect: opts.copyOnSelect, pasteDelayMs: opts.pasteDelayMs }), + ); +} + +function normalizeSessionOptions(draft: SessionOptions): SessionOptions { + const background = normalizeHexColor(draft.background, COLOR_SCHEMES.dark.background); + const foreground = normalizeHexColor(draft.foreground, COLOR_SCHEMES.dark.foreground); + let colorScheme = draft.colorScheme; + if (colorScheme !== "custom" && colorScheme in COLOR_SCHEMES) { + const preset = COLOR_SCHEMES[colorScheme as Exclude]; + return { + encoding: draft.encoding || "utf-8", + fontSize: Math.max(10, Math.min(28, Number(draft.fontSize) || 13)), + colorScheme, + background: preset.background, + foreground: preset.foreground, + copyOnSelect: draft.copyOnSelect !== false, + pasteDelayMs: Math.max(0, Math.min(200, Number(draft.pasteDelayMs) || 0)), + keepaliveSec: Math.max(0, Math.min(600, Number(draft.keepaliveSec) || 0)), + }; + } + return { + encoding: draft.encoding || "utf-8", + fontSize: Math.max(10, Math.min(28, Number(draft.fontSize) || 13)), + colorScheme: matchColorScheme(background, foreground), + background, + foreground, + copyOnSelect: draft.copyOnSelect !== false, + pasteDelayMs: Math.max(0, Math.min(200, Number(draft.pasteDelayMs) || 0)), + keepaliveSec: Math.max(0, Math.min(600, Number(draft.keepaliveSec) || 0)), + }; +} + +const emptyHostForm = (): HostForm => ({ + name: "", + ip_address: "", + port: 22, + protocol: "ssh", +}); + +const emptyAuthForm = (username = ""): AuthForm => ({ + username, + password: "", + savePassword: true, +}); + +function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) { + return ( + + {children} + {required ? ( + + ) : null} + + ); +} + function targetKey(t: Pick): string { return `${t.source}:${t.id}`; } @@ -46,18 +269,66 @@ function ComputerIcon() { ); } +function isSshAuthFailure(err: unknown): boolean { + const raw = String(err).toLowerCase(); + return ( + raw.includes("auth_rejected") || + raw.includes("authentication") || + raw.includes("auth_failed") || + raw.includes("permission denied") || + raw.includes("credentials_incomplete") || + raw.includes("password rejected") || + raw.includes("incorrect password") + ); +} + +function connectFailedNe(err: unknown): ManagedNeItem | null { + if (!(err instanceof ApiRequestError)) return null; + const detail = err.detail; + if (!detail || typeof detail !== "object" || Array.isArray(detail)) return null; + const ne = (detail as { ne?: unknown }).ne; + if (!ne || typeof ne !== "object") return null; + const id = String((ne as ManagedNeItem).id || "").trim(); + if (!id) return null; + return ne as ManagedNeItem; +} + function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record) => string): string { const raw = String(err); if (raw.includes("webcrt_session_limit")) return t("webcrt.err.sessionLimit"); if (raw.includes("managed_ne_not_found") || raw.includes("ume_ne_not_found")) return t("webcrt.err.neNotFound"); - if (raw.includes("credentials_incomplete") || raw.includes("cli_username_required")) return t("webcrt.err.credsIncomplete"); + if (raw.includes("credentials_incomplete") || raw.includes("cli_username_required") || raw.includes("password_required")) + return t("webcrt.err.credsIncomplete"); + if (isSshAuthFailure(raw)) return t("webcrt.err.authRejected"); + if (raw.includes("unsupported_device_type")) return t("webcrt.err.deviceType"); + if (raw.includes("ip_address_conflict_restart_required")) return t("webcrt.err.ipConflictRestart"); + if (raw.includes("ip_address_required")) return t("webcrt.newSession.ipRequired"); if (raw.includes("cli_connect_profile_not_configured")) return t("webcrt.err.cliProfile"); - if (raw.includes("connect_failed")) return t("webcrt.err.connectFailed", { detail: raw.replace(/^.*connect_failed:/, "") }); + if (raw.includes("connect_failed")) { + const detail = raw.replace(/^.*connect_failed:/, "").split("\n---")[0].trim(); + return t("webcrt.err.connectFailed", { detail: detail.slice(0, 180) }); + } if (raw.includes("sftp_hop_not_supported")) return t("webcrt.err.sftpHop"); if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh"); return raw; } +function targetFromManagedNe(ne: ManagedNeItem, fallback?: Partial): CliTargetItem { + return { + source: "webcrt", + id: ne.id, + name: ne.name || fallback?.name || ne.ip_address || "", + ip_address: ne.ip_address || fallback?.ip_address || "", + vendor: ne.vendor || fallback?.vendor, + device_type: ne.device_type || fallback?.device_type, + protocol: ne.protocol || fallback?.protocol || "ssh", + username: ne.username || fallback?.username || "", + has_password: Boolean(fallback?.has_password), + connect_status: ne.connect_status || "unknown", + cli_profile_ready: true, + }; +} + function downloadText(filename: string, text: string) { const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); @@ -71,11 +342,12 @@ function downloadText(filename: string, text: string) { export function WebcrtPage() { const { t } = useI18n(); const { showOk, showError } = useToast(); + const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const presetNeId = String(searchParams.get("ne_id") || "").trim(); const presetSource = String(searchParams.get("source") || "managed").trim().toLowerCase(); - const [source, setSource] = useState<"all" | "managed" | "ume">("all"); + const [source, setSource] = useState<"all" | "managed" | "webcrt" | "ume">("all"); const [keywordInput, setKeywordInput] = useState(""); const [keyword, setKeyword] = useState(""); const [page, setPage] = useState(1); @@ -84,11 +356,93 @@ export function WebcrtPage() { const [sftpOpen, setSftpOpen] = useState(false); const [sftpPath, setSftpPath] = useState("."); const [sftpItems, setSftpItems] = useState>([]); + const [sessionOpts, setSessionOpts] = useState(() => loadSessionOptions()); + const [optionsMenuOpen, setOptionsMenuOpen] = useState(false); + const [sessionOptsModalOpen, setSessionOptsModalOpen] = useState(false); + const [sessionOptsDraft, setSessionOptsDraft] = useState(() => loadSessionOptions()); + const [keywordHl, setKeywordHl] = useState(() => loadKeywordHighlightConfig()); + const [keywordHlModalOpen, setKeywordHlModalOpen] = useState(false); + const [keywordHlDraft, setKeywordHlDraft] = useState(() => + loadKeywordHighlightConfig(), + ); + const [keywordDraftText, setKeywordDraftText] = useState(""); + const [keywordDraftRegex, setKeywordDraftRegex] = useState(false); + const [keywordSelectedId, setKeywordSelectedId] = useState(""); + const [tabMenu, setTabMenu] = useState(null); + const [treeMenu, setTreeMenu] = useState(null); + const [renameDialog, setRenameDialog] = useState<{ target: CliTargetItem; name: string } | null>(null); + const [hostDialogOpen, setHostDialogOpen] = useState(false); + const [hostForm, setHostForm] = useState(() => emptyHostForm()); + const [authDialog, setAuthDialog] = useState(null); + const [authForm, setAuthForm] = useState(() => emptyAuthForm()); + const [sessionBusy, setSessionBusy] = useState(false); const connectingKeysRef = useRef>(new Set()); const tabsRef = useRef([]); tabsRef.current = tabs; const termRefs = useRef>(new Map()); const logBuffersRef = useRef>(new Map()); + const optionsMenuRef = useRef(null); + const tabMenuRef = useRef(null); + const treeMenuRef = useRef(null); + const sessionOptsRef = useRef(sessionOpts); + sessionOptsRef.current = sessionOpts; + + useEffect(() => { + if (!optionsMenuOpen) return; + const onDoc = (e: MouseEvent) => { + const el = optionsMenuRef.current; + if (el && e.target instanceof Node && !el.contains(e.target)) { + setOptionsMenuOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOptionsMenuOpen(false); + }; + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + }; + }, [optionsMenuOpen]); + + useEffect(() => { + if (!tabMenu) return; + const onDoc = (e: MouseEvent) => { + const el = tabMenuRef.current; + if (el && e.target instanceof Node && !el.contains(e.target)) { + setTabMenu(null); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setTabMenu(null); + }; + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + }; + }, [tabMenu]); + + useEffect(() => { + if (!treeMenu) return; + const onDoc = (e: MouseEvent) => { + const el = treeMenuRef.current; + if (el && e.target instanceof Node && !el.contains(e.target)) { + setTreeMenu(null); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setTreeMenu(null); + }; + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + }; + }, [treeMenu]); useEffect(() => { const timer = window.setTimeout(() => { @@ -112,6 +466,48 @@ export function WebcrtPage() { setTabs((prev) => prev.map((tab) => (tab.key === key ? { ...tab, ...patch } : tab))); }, []); + const attachSessionResult = useCallback( + (target: CliTargetItem, sessionId: string, encoding: string) => { + const key = targetKey(target); + const existing = tabsRef.current.find((tab) => tab.key === key); + const pending: TermTab = { + key, + sessionId, + wsUrl: webcrtWsUrl(sessionId), + termEpoch: (existing?.termEpoch || 0) + 1, + target, + status: "connecting", + connectPhase: "authenticating", + recording: existing?.recording || false, + encoding, + errorMessage: undefined, + }; + setTabs((prev) => { + const without = prev.filter((x) => x.key !== key); + return [...without, pending]; + }); + setActiveTabKey(key); + showOk(t("webcrt.opened", { name: deviceLabel(target) })); + }, + [showOk, t], + ); + + const openAuthForTarget = useCallback((target: CliTargetItem, errorHint?: string) => { + const proto = String(target.protocol || "ssh").toLowerCase() === "telnet" ? "telnet" : "ssh"; + setAuthForm(emptyAuthForm(String(target.username || ""))); + setAuthDialog({ + mode: "retry", + host: { + name: target.name || "", + ip_address: target.ip_address || "", + port: proto === "telnet" ? 23 : 22, + protocol: proto === "telnet" ? "telnet" : "ssh", + }, + target, + errorHint, + }); + }, []); + const openTarget = useCallback( async (target: CliTargetItem, opts?: { force?: boolean }) => { const key = targetKey(target); @@ -123,6 +519,15 @@ export function WebcrtPage() { return; } if (connectingKeysRef.current.has(key)) return; + + // WebCRT SSH without saved password → credential popup. + const isWebcrtSsh = + target.source === "webcrt" && String(target.protocol || "ssh").toLowerCase() !== "telnet"; + if (isWebcrtSsh && !target.has_password && !opts?.force) { + openAuthForTarget(target); + return; + } + connectingKeysRef.current.add(key); if (opts?.force && existing?.sessionId) { @@ -133,6 +538,9 @@ export function WebcrtPage() { } } + const optsNow = sessionOptsRef.current; + const encoding = optsNow.encoding || "utf-8"; + const keepaliveSec = Math.max(0, Math.min(600, Number(optsNow.keepaliveSec) || 0)); const pending: TermTab = { key, sessionId: "", @@ -140,7 +548,10 @@ export function WebcrtPage() { termEpoch: (existing?.termEpoch || 0) + (opts?.force ? 1 : 0), target, status: "connecting", + connectPhase: "creating", recording: existing?.recording || false, + encoding, + errorMessage: undefined, }; setTabs((prev) => { const without = prev.filter((x) => x.key !== key); @@ -157,12 +568,16 @@ export function WebcrtPage() { ume_ne_id: target.ume_ne_id || target.id, cols, rows, + encoding, + keepalive_sec: keepaliveSec, async_connect: true, } : { ne_id: target.id, cols, rows, + encoding, + keepalive_sec: keepaliveSec, async_connect: true, }; const sess = await createWebcrtSession(body); @@ -171,23 +586,222 @@ export function WebcrtPage() { sessionId: sess.session_id, wsUrl, status: "connecting", + connectPhase: "authenticating", termEpoch: pending.termEpoch + 1, }); showOk(t("webcrt.opened", { name: deviceLabel(target) })); } catch (err) { const message = webcrtErrorMessage(err, t); - updateTab(key, { status: "error", errorMessage: message }); - showError(message); + const needAuth = + target.source === "webcrt" && + String(target.protocol || "ssh").toLowerCase() !== "telnet" && + (String(err).includes("credentials_incomplete") || + String(err).includes("connect_failed") || + isSshAuthFailure(err)); + updateTab(key, { status: "error", connectPhase: undefined, errorMessage: message }); + if (needAuth) { + openAuthForTarget(target, message); + } else { + showError(message); + } } finally { connectingKeysRef.current.delete(key); } }, - [showOk, showError, t, updateTab], + [openAuthForTarget, showOk, showError, t, updateTab], ); const openTargetRef = useRef(openTarget); openTargetRef.current = openTarget; + const sessionDims = useCallback(() => { + const optsNow = sessionOptsRef.current; + return { + encoding: optsNow.encoding || "utf-8", + keepaliveSec: Math.max(0, Math.min(600, Number(optsNow.keepaliveSec) || 0)), + cols: Math.max(80, Math.floor((window.innerWidth - 360) / 9)), + rows: Math.max(24, Math.floor((window.innerHeight - 180) / 18)), + }; + }, []); + + const submitHostDialog = useCallback(() => { + const ip = hostForm.ip_address.trim(); + if (!ip) { + showError(t("webcrt.newSession.ipRequired")); + return; + } + const host: HostForm = { + ...hostForm, + ip_address: ip, + name: hostForm.name.trim() || ip, + port: hostForm.port || (hostForm.protocol === "telnet" ? 23 : 22), + }; + if (host.protocol === "telnet") { + void (async () => { + setSessionBusy(true); + try { + const dims = sessionDims(); + const result = await quickConnectWebcrtSession({ + name: host.name, + ip_address: host.ip_address, + port: host.port, + protocol: "telnet", + save_password: false, + cols: dims.cols, + rows: dims.rows, + encoding: dims.encoding, + keepalive_sec: dims.keepaliveSec, + async_connect: true, + }); + const listSource = result.list_source === "managed" ? "managed" : "webcrt"; + const target: CliTargetItem = { + source: listSource, + id: result.ne.id, + name: result.ne.name || result.ne_name || host.ip_address, + ip_address: result.ne.ip_address || result.ne_ip || host.ip_address, + vendor: result.ne.vendor, + device_type: result.ne.device_type, + protocol: result.ne.protocol || "telnet", + username: result.ne.username || "", + has_password: false, + connect_status: result.ne.connect_status || "unknown", + cli_profile_ready: true, + }; + attachSessionResult(target, result.session_id, dims.encoding); + setHostDialogOpen(false); + setHostForm(emptyHostForm()); + setSource(listSource === "managed" ? "managed" : "webcrt"); + void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); + } catch (err) { + showError(webcrtErrorMessage(err, t)); + } finally { + setSessionBusy(false); + } + })(); + return; + } + setHostDialogOpen(false); + setAuthForm(emptyAuthForm()); + setAuthDialog({ mode: "quick", host }); + }, [attachSessionResult, hostForm, queryClient, sessionDims, showError, t]); + + const submitAuthDialog = useCallback(async () => { + if (!authDialog) return; + const username = authForm.username.trim(); + if (!username) { + showError(t("webcrt.newSession.userRequired")); + return; + } + if (!authForm.password) { + showError(t("webcrt.newSession.passwordRequired")); + return; + } + setSessionBusy(true); + const dims = sessionDims(); + const host = authDialog.host; + const ip = (authDialog.target?.ip_address || host.ip_address).trim(); + try { + if (authDialog.mode === "retry" && authDialog.target) { + const existing = authDialog.target; + if (authForm.savePassword && existing.source === "webcrt") { + try { + await updateManagedNe(existing.id, { username, password: authForm.password }); + } catch { + /* connect still proceeds with one-shot creds */ + } + } + const sess = await createWebcrtSession({ + ne_id: existing.id, + cols: dims.cols, + rows: dims.rows, + encoding: dims.encoding, + keepalive_sec: dims.keepaliveSec, + async_connect: false, + username, + password: authForm.password, + }); + attachSessionResult( + { + ...existing, + username, + has_password: authForm.savePassword || Boolean(existing.has_password), + }, + sess.session_id, + dims.encoding, + ); + setAuthDialog(null); + setAuthForm(emptyAuthForm()); + void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); + return; + } + + const result = await quickConnectWebcrtSession({ + name: host.name || ip, + ip_address: ip, + port: host.port || 22, + protocol: "ssh", + username, + password: authForm.password, + save_password: authForm.savePassword, + cols: dims.cols, + rows: dims.rows, + encoding: dims.encoding, + keepalive_sec: dims.keepaliveSec, + async_connect: false, + }); + const target: CliTargetItem = { + source: "webcrt", + id: result.ne.id, + name: result.ne.name || result.ne_name || ip, + ip_address: result.ne.ip_address || result.ne_ip || ip, + vendor: result.ne.vendor, + device_type: result.ne.device_type, + protocol: result.ne.protocol || "ssh", + username: result.ne.username || username, + has_password: authForm.savePassword, + connect_status: result.ne.connect_status || "unknown", + cli_profile_ready: true, + }; + attachSessionResult(target, result.session_id, dims.encoding); + setAuthDialog(null); + setAuthForm(emptyAuthForm()); + setSource("webcrt"); + void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); + } catch (err) { + const message = webcrtErrorMessage(err, t); + // Keep dialog open; allow fixing username and/or password. + setAuthForm((prev) => ({ ...prev, password: "" })); + const failedNe = connectFailedNe(err); + if (failedNe) { + const target = targetFromManagedNe(failedNe, { + name: host.name || ip, + ip_address: ip, + username, + has_password: authForm.savePassword, + protocol: "ssh", + }); + setAuthDialog({ + mode: "retry", + host: { + name: target.name, + ip_address: target.ip_address, + port: host.port || 22, + protocol: "ssh", + }, + target: { ...target, username }, + errorHint: message, + }); + setAuthForm((prev) => ({ ...prev, username, password: "" })); + setSource("webcrt"); + void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); + } else { + setAuthDialog((prev) => (prev ? { ...prev, errorHint: message } : prev)); + } + } finally { + setSessionBusy(false); + } + }, [attachSessionResult, authDialog, authForm, queryClient, sessionDims, showError, t]); + const closeTab = useCallback( async (key: string) => { const tab = tabsRef.current.find((x) => x.key === key); @@ -211,6 +825,62 @@ export function WebcrtPage() { [activeTabKey], ); + const closeOtherTabs = useCallback(async (keepKey: string) => { + const others = tabsRef.current.filter((x) => x.key !== keepKey); + await Promise.all( + others.map(async (tab) => { + if (tab.sessionId) { + try { + await closeWebcrtSession(tab.sessionId); + } catch { + /* ignore */ + } + } + logBuffersRef.current.delete(tab.key); + termRefs.current.delete(tab.key); + }), + ); + setTabs((prev) => prev.filter((x) => x.key === keepKey)); + setActiveTabKey(keepKey); + }, []); + + const closeAllTabs = useCallback(async () => { + const all = [...tabsRef.current]; + await Promise.all( + all.map(async (tab) => { + if (tab.sessionId) { + try { + await closeWebcrtSession(tab.sessionId); + } catch { + /* ignore */ + } + } + logBuffersRef.current.delete(tab.key); + termRefs.current.delete(tab.key); + }), + ); + setTabs([]); + setActiveTabKey(""); + }, []); + + const copyTabIp = useCallback( + async (key: string) => { + const tab = tabsRef.current.find((x) => x.key === key); + const ip = String(tab?.target.ip_address || "").trim(); + if (!ip) { + showError(t("webcrt.tabMenu.copyIpEmpty")); + return; + } + try { + await navigator.clipboard.writeText(ip); + showOk(t("webcrt.tabMenu.copyIpOk", { ip })); + } catch { + showError(t("webcrt.actions.copyFailed")); + } + }, + [showError, showOk, t], + ); + const reconnectActive = useCallback(async () => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); if (!tab) return; @@ -328,10 +998,53 @@ export function WebcrtPage() { const activeTab = tabs.find((x) => x.key === activeTabKey) || null; + const renameWebcrtSession = useCallback( + async (target: CliTargetItem, nextName: string) => { + const name = nextName.trim(); + if (!name) { + showError(t("webcrt.treeMenu.renameRequired")); + return; + } + if (target.source !== "webcrt") return; + try { + await updateManagedNe(target.id, { name }); + const key = targetKey(target); + setTabs((prev) => + prev.map((tab) => (tab.key === key ? { ...tab, target: { ...tab.target, name } } : tab)), + ); + void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); + showOk(t("webcrt.treeMenu.renamed")); + setRenameDialog(null); + } catch (err) { + showError(webcrtErrorMessage(err, t)); + } + }, + [queryClient, showError, showOk, t], + ); + + const deleteWebcrtSession = useCallback( + async (target: CliTargetItem) => { + if (target.source !== "webcrt") return; + const label = deviceLabel(target); + if (!window.confirm(t("webcrt.treeMenu.deleteConfirm", { name: label }))) return; + try { + const key = targetKey(target); + await closeTab(key); + await deleteManagedNe(target.id); + void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); + showOk(t("webcrt.treeMenu.deleted")); + } catch (err) { + showError(webcrtErrorMessage(err, t)); + } + }, + [closeTab, queryClient, showError, showOk, t], + ); + const renderDeviceRow = (row: CliTargetItem) => { const key = targetKey(row); const tab = tabs.find((x) => x.key === key); const isConnecting = tab?.status === "connecting" || connectingKeysRef.current.has(key); + const isWebcrtSession = row.source === "webcrt"; return (
  • - ))} +
    + + {optionsMenuOpen ? ( +
    + + +
    + ) : null} +
    + +
    + setKeywordInput(e.target.value)} + /> +
    +
    + {(["all", "managed", "webcrt", "ume"] as const).map((s) => ( + + ))} +
    {targetsQuery.isLoading ?
    {t("common.refreshing")}
    : null} @@ -410,45 +1187,68 @@ export function WebcrtPage() { {tabs.length > 0 ? ( <>
    - {tabs.map((tab) => ( -
    e.preventDefault()} - onClick={() => { - setActiveTabKey(tab.key); - window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 0); - }} - > - - {deviceLabel(tab.target)} - {tab.status === "connecting" ? ` (${t("webcrt.status.connecting")})` : ""} - { - e.stopPropagation(); - void closeTab(tab.key); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); + {tabs.map((tab) => { + const dead = tab.status === "closed" || tab.status === "error"; + return ( +
    { + if (e.button === 0) e.preventDefault(); + }} + onClick={() => { + setActiveTabKey(tab.key); + setTabMenu(null); + window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 0); + }} + onContextMenu={(e) => { + e.preventDefault(); + setActiveTabKey(tab.key); + setTabMenu({ key: tab.key, x: e.clientX, y: e.clientY }); + }} + > + + {deviceLabel(tab.target)} + {tab.status === "connecting" + ? ` (${connectPhaseLabel(tab.connectPhase, t)})` + : ""} + {tab.status === "closed" ? ` (${t("webcrt.status.closed")})` : ""} + {tab.status === "error" ? ` (${t("webcrt.status.error")})` : ""} + { e.stopPropagation(); void closeTab(tab.key); - } - }} - > - × + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + void closeTab(tab.key); + } + }} + > + × + - -
    - ))} +
    + ); + })}
    {activeTab ? (
    - @@ -488,43 +1288,125 @@ export function WebcrtPage() { hidden={activeTabKey !== tab.key} > {tab.status === "connecting" && !tab.wsUrl ? ( -
    {t("webcrt.status.connecting")}…
    +
    +
    {connectPhaseLabel(tab.connectPhase, t)}…
    +

    {t("webcrt.phase.hint")}

    +
    ) : null} {tab.status === "error" && !tab.wsUrl ? (
    {t("webcrt.status.error")}
    {tab.errorMessage ?
    {tab.errorMessage}
    : null} +
    ) : null} {tab.wsUrl ? ( - { - if (handle) termRefs.current.set(tab.key, handle); - else termRefs.current.delete(tab.key); - }} - wsUrl={tab.wsUrl} - title={deviceLabel(tab.target)} - recording={tab.recording} - autoFocus={activeTabKey === tab.key} - onStdout={(chunk) => { - const prev = logBuffersRef.current.get(tab.key) || ""; - logBuffersRef.current.set(tab.key, prev + chunk); - }} - onReady={() => { - window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40); - }} - onStatus={(state) => { - if (state === "open" || state === "connected") { - updateTab(tab.key, { status: "connected" }); - if (activeTabKey === tab.key) { - window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40); + <> + {tab.status === "connecting" ? ( +
    +
    + {connectPhaseLabel(tab.connectPhase, t)}… + {t("webcrt.phase.hint")} +
    +
    + ) : null} + {tab.status === "closed" || tab.status === "error" ? ( +
    +
    + + {tab.status === "error" + ? t("webcrt.disconnectBannerError") + : t("webcrt.disconnectBanner")} + + {tab.errorMessage ? {tab.errorMessage} : null} +
    + +
    + ) : null} + { + if (handle) termRefs.current.set(tab.key, handle); + else termRefs.current.delete(tab.key); + }} + wsUrl={tab.wsUrl} + title={deviceLabel(tab.target)} + recording={tab.recording} + encoding={tab.encoding} + fontSize={sessionOpts.fontSize} + termColors={{ + background: sessionOpts.background, + foreground: sessionOpts.foreground, + }} + copyOnSelect={sessionOpts.copyOnSelect} + pasteDelayMs={sessionOpts.pasteDelayMs} + keywordHighlight={keywordHl} + autoFocus={activeTabKey === tab.key && tab.status === "connected"} + onStdout={(chunk) => { + const prev = logBuffersRef.current.get(tab.key) || ""; + logBuffersRef.current.set(tab.key, prev + chunk); + }} + onReady={() => { + window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40); + }} + onStatus={(state, message, phase) => { + if (state === "connected") { + updateTab(tab.key, { + status: "connected", + connectPhase: undefined, + errorMessage: undefined, + }); + if (activeTabKey === tab.key) { + window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40); + } + } else if (state === "open" || state === "connecting") { + const nextPhase: ConnectPhase = + phase === "waiting_prompt" || message === "waiting_prompt" + ? "waiting_prompt" + : "authenticating"; + updateTab(tab.key, { + status: "connecting", + connectPhase: nextPhase, + }); + } else if (state === "error") { + const errMsg = message || t("webcrt.disconnectBannerError"); + const sid = tab.sessionId; + updateTab(tab.key, { + status: "error", + sessionId: "", + wsUrl: "", + connectPhase: undefined, + errorMessage: errMsg, + }); + if (sid) { + void closeWebcrtSession(sid).catch(() => undefined); + } + const tgt = tab.target; + const isWebcrtSsh = + tgt.source === "webcrt" && + String(tgt.protocol || "ssh").toLowerCase() !== "telnet"; + if (isWebcrtSsh && isSshAuthFailure(errMsg)) { + openAuthForTarget(tgt, webcrtErrorMessage(errMsg, t)); + } + } else if (state === "closed") { + // Ignore local socket teardown noise; real device closes send session closed. + if (String(message || "").startsWith("websocket_closed:")) return; + updateTab(tab.key, { + status: "closed", + connectPhase: undefined, + errorMessage: message || t("webcrt.disconnectBanner"), + }); } - } else if (state === "connecting") { - updateTab(tab.key, { status: "connecting" }); - } else if (state === "error") updateTab(tab.key, { status: "error" }); - else if (state === "closed") updateTab(tab.key, { status: "closed" }); - }} - /> + }} + /> + ) : null}
    ))} @@ -620,6 +1502,680 @@ export function WebcrtPage() {
    )} + + {treeMenu ? ( +
    + + +
    + ) : null} + + {renameDialog ? ( +
    setRenameDialog(null)} + > +
    e.stopPropagation()} + > +

    {t("webcrt.treeMenu.rename")}

    +

    + {renameDialog.target.ip_address} + {renameDialog.target.protocol ? ` · ${String(renameDialog.target.protocol).toUpperCase()}` : ""} +

    +
    + +
    +
    + + +
    +
    +
    + ) : null} + + {tabMenu ? ( +
    + + + + +
    + ) : null} + + {keywordHlModalOpen ? ( +
    setKeywordHlModalOpen(false)} + > +
    e.stopPropagation()} + > +

    {t("webcrt.keywordHl.title")}

    + +
    + + + +
    +
    +
    + {t("webcrt.keywordHl.colKeyword")} + {t("webcrt.keywordHl.colRegex")} +
    +
      + {keywordHlDraft.keywords.length === 0 ? ( +
    • {t("webcrt.keywordHl.empty")}
    • + ) : ( + keywordHlDraft.keywords.map((kw) => ( +
    • + +
    • + )) + )} +
    +
    + + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + ) : null} + + {sessionOptsModalOpen ? ( +
    setSessionOptsModalOpen(false)} + > +
    e.stopPropagation()} + > +

    {t("webcrt.globalSessionOptions")}

    +
    + + + +
    + + +
    + Aa 192.168.0.1 +
    +
    + + + +
    +
    + + +
    +
    +
    + ) : null} + + {authDialog ? ( +
    { + if (!sessionBusy) setAuthDialog(null); + }} + > +
    e.stopPropagation()} + > +

    {t("webcrt.newSession.authTitle")}

    +

    + {authDialog.host.ip_address} + {authDialog.host.port ? `:${authDialog.host.port}` : ""} + {" · SSH"} +

    + {authDialog.errorHint ? ( +

    {authDialog.errorHint}

    + ) : null} +
    + + + +
    +
    + + +
    +
    +
    + ) : null} + + {hostDialogOpen ? ( +
    { + if (!sessionBusy) setHostDialogOpen(false); + }} + > +
    e.stopPropagation()} + > +

    {t("webcrt.newSession.title")}

    +

    {t("webcrt.newSession.hint")}

    +
    + + + + +
    +
    + + +
    +
    +
    + ) : null} + ); } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 4ceacc1..af2acc4 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -92,6 +92,46 @@ const parseApiResponse = async (res: Response): Promise> } }; +/** Preserve FastAPI structured ``detail`` objects (e.g. connect_failed + ne). */ +export class ApiRequestError extends Error { + status: number; + detail: unknown; + + constructor(status: number, detail: unknown) { + super(formatApiDetail(detail) || `${status}`); + this.name = "ApiRequestError"; + this.status = status; + this.detail = detail; + } +} + +export function formatApiDetail(detail: unknown): string { + if (typeof detail === "string") return detail; + if (Array.isArray(detail)) { + return detail + .map((item) => { + if (typeof item === "string") return item; + if (item && typeof item === "object" && "msg" in item) { + return String((item as { msg?: unknown }).msg || ""); + } + return ""; + }) + .filter(Boolean) + .join("; "); + } + if (detail && typeof detail === "object") { + const obj = detail as Record; + if (typeof obj.message === "string" && obj.message.trim()) return obj.message; + if (typeof obj.error === "string" && obj.error.trim()) return obj.error; + try { + return JSON.stringify(detail); + } catch { + /* fall through */ + } + } + return detail == null ? "" : String(detail); +} + export const apiGet = async (path: string): Promise => { const res = await fetch(path, { headers: authHeaders() }); if (res.status === 401) { @@ -111,9 +151,9 @@ export const apiPost = async (path: string, body: unknown): Promise => { const data = await parseApiResponse(res); if (res.status === 401) { handleUnauthorized(path); - throw new Error(String(data.detail || "unauthorized")); + throw new ApiRequestError(401, data.detail || "unauthorized"); } - if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`)); + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); return data as T; }; @@ -126,9 +166,9 @@ export const apiPatch = async (path: string, body: unknown): Promise => { const data = await parseApiResponse(res); if (res.status === 401) { handleUnauthorized(path); - throw new Error(String(data.detail || "unauthorized")); + throw new ApiRequestError(401, data.detail || "unauthorized"); } - if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`)); + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); return data as T; }; @@ -137,9 +177,9 @@ export const apiDelete = async (path: string): Promise => { const data = await parseApiResponse(res); if (res.status === 401) { handleUnauthorized(path); - throw new Error(String(data.detail || "unauthorized")); + throw new ApiRequestError(401, data.detail || "unauthorized"); } - if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`)); + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); return data as T; }; @@ -152,9 +192,9 @@ export const apiPut = async (path: string, body: unknown): Promise => { const data = await parseApiResponse(res); if (res.status === 401) { handleUnauthorized(path); - throw new Error(String(data.detail || "unauthorized")); + throw new ApiRequestError(401, data.detail || "unauthorized"); } - if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`)); + if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`); return data as T; }; @@ -471,10 +511,34 @@ export const createWebcrtSession = (body: { cols?: number; rows?: number; encoding?: string; + keepalive_sec?: number; post_login_commands?: string[]; async_connect?: boolean; + username?: string; + password?: string; }) => apiPost("/v1/webcrt/sessions", body); +export type WebcrtQuickConnectResult = WebcrtSessionCreateResult & { + ne: ManagedNeItem; + ne_action: "created" | "updated" | "reused" | string; + list_source: "webcrt" | "managed" | string; +}; + +export const quickConnectWebcrtSession = (body: { + name?: string; + ip_address: string; + port?: number; + protocol?: string; + username?: string; + password?: string; + save_password?: boolean; + cols?: number; + rows?: number; + encoding?: string; + keepalive_sec?: number; + async_connect?: boolean; +}) => apiPost("/v1/webcrt/sessions/quick-connect", body); + export const closeWebcrtSession = (sessionId: string) => apiDelete<{ ok: boolean; session_id: string; closed: boolean }>( `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`, @@ -565,7 +629,7 @@ export const fetchCliProfiles = () => apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles"); export const fetchCliTargets = (params: { - source?: "managed" | "ume" | "all"; + source?: "managed" | "ume" | "webcrt" | "all"; keyword?: string; page?: number; pageSize?: number; diff --git a/web/src/types.ts b/web/src/types.ts index 6377a8f..a37ddf7 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -257,6 +257,9 @@ export type CliTargetItem = { ne_type?: string; vendor?: string; device_type?: string; + protocol?: string; + username?: string; + has_password?: boolean; connect_status: string; cli_profile_ready?: boolean; }; diff --git a/web/src/utils/webcrtKeywordHighlight.ts b/web/src/utils/webcrtKeywordHighlight.ts new file mode 100644 index 0000000..fbc3388 --- /dev/null +++ b/web/src/utils/webcrtKeywordHighlight.ts @@ -0,0 +1,189 @@ +/** SecureCRT-like keyword highlighting for WebCRT stdout (ANSI inject). */ + +export type KeywordRule = { + id: string; + pattern: string; + regex: boolean; +}; + +export type KeywordHighlightConfig = { + enabled: boolean; + caseSensitive: boolean; + /** Foreground (character) highlight color (hex). */ + color: string; + keywords: KeywordRule[]; +}; + +export const KEYWORD_HL_STORAGE_KEY = "netx.webcrt.keywordHighlight"; + +const DEFAULT_COLOR = "#ffff00"; + +export function defaultKeywordHighlightConfig(): KeywordHighlightConfig { + return { + enabled: false, + caseSensitive: false, + color: DEFAULT_COLOR, + keywords: [], + }; +} + +export function newKeywordId(): string { + return `kw_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +function normalizeHexColor(hex: string): string | null { + const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex || "").trim()); + return m ? `#${m[1].toLowerCase()}` : null; +} + +export function loadKeywordHighlightConfig(): KeywordHighlightConfig { + const base = defaultKeywordHighlightConfig(); + try { + const raw = localStorage.getItem(KEYWORD_HL_STORAGE_KEY); + if (!raw) return base; + const j = JSON.parse(raw) as Partial; + const keywords = Array.isArray(j.keywords) + ? j.keywords + .map((k) => ({ + id: String((k as KeywordRule).id || newKeywordId()), + pattern: String((k as KeywordRule).pattern || ""), + regex: Boolean((k as KeywordRule).regex), + })) + .filter((k) => k.pattern.trim()) + : []; + return { + // Keywords imply active highlighting (avoids stale enabled:false in localStorage). + enabled: keywords.length > 0, + caseSensitive: Boolean(j.caseSensitive), + color: normalizeHexColor(String(j.color || base.color)) || DEFAULT_COLOR, + keywords, + }; + } catch { + return base; + } +} + +export function saveKeywordHighlightConfig(cfg: KeywordHighlightConfig): void { + const keywords = (cfg.keywords || []).filter((k) => String(k.pattern || "").trim()); + localStorage.setItem( + KEYWORD_HL_STORAGE_KEY, + JSON.stringify({ + ...cfg, + enabled: keywords.length > 0 && cfg.enabled !== false, + keywords, + }), + ); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const n = normalizeHexColor(hex) || DEFAULT_COLOR; + const v = parseInt(n.slice(1), 16); + return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 }; +} + +/** Map highlight color to bold foreground (character color), not background. */ +function sgrForColor(hex: string): { open: string; close: string } { + const { r, g, b } = hexToRgb(hex); + // 16-color bright fg fallback + truecolor fg for xterm. + let brightFg = 93; // bright yellow + if (g >= r && g >= b && g > 80) brightFg = 92; // bright green + else if (r >= g && r >= b && r > 80 && g < 100) brightFg = 91; // bright red + else if (b >= r && b >= g && b > 80) brightFg = 94; // bright blue + else if (r > 180 && g > 180 && b < 120) brightFg = 93; // yellow + const open = `\x1b[1;${brightFg}m\x1b[38;2;${r};${g};${b}m`; + const close = "\x1b[22;39m"; + return { open, close }; +} + +type CompiledRule = { + re: RegExp; + open: string; + close: string; +}; + +export function compileKeywordRules(config: KeywordHighlightConfig | null | undefined): CompiledRule[] { + if (!config?.keywords?.length) return []; + if (!config.enabled) return []; + const { open, close } = sgrForColor(config.color || DEFAULT_COLOR); + const flags = config.caseSensitive ? "g" : "gi"; + const out: CompiledRule[] = []; + for (const kw of config.keywords) { + const pattern = String(kw.pattern || "").trim(); + if (!pattern) continue; + try { + const source = kw.regex ? pattern : escapeRegExp(pattern); + out.push({ re: new RegExp(source, flags), open, close }); + } catch { + /* skip invalid regex */ + } + } + return out; +} + +/** CSI / OSC / simple charset sequences — leave untouched. */ +const ANSI_RE = + /\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[()][0-2AB]|[>=])/g; + +function highlightPlain(text: string, rules: CompiledRule[]): string { + if (!text || !rules.length) return text; + type Match = { start: number; end: number; open: string; close: string }; + const matches: Match[] = []; + for (const rule of rules) { + rule.re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = rule.re.exec(text)) !== null) { + if (!m[0]) { + rule.re.lastIndex += 1; + continue; + } + matches.push({ + start: m.index, + end: m.index + m[0].length, + open: rule.open, + close: rule.close, + }); + if (!rule.re.global) break; + } + } + if (!matches.length) return text; + matches.sort((a, b) => a.start - b.start || b.end - a.end); + const picked: Match[] = []; + let cursor = 0; + for (const m of matches) { + if (m.start < cursor) continue; + picked.push(m); + cursor = m.end; + } + let out = ""; + let i = 0; + for (const m of picked) { + out += text.slice(i, m.start) + m.open + text.slice(m.start, m.end) + m.close; + i = m.end; + } + out += text.slice(i); + return out; +} + +export function applyKeywordHighlight( + text: string, + config: KeywordHighlightConfig | null | undefined, +): string { + if (!text) return text; + const rules = compileKeywordRules(config); + if (!rules.length) return text; + let out = ""; + let last = 0; + ANSI_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = ANSI_RE.exec(text)) !== null) { + if (m.index > last) out += highlightPlain(text.slice(last, m.index), rules); + out += m[0]; + last = m.index + m[0].length; + } + if (last < text.length) out += highlightPlain(text.slice(last), rules); + return out; +}