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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 22:52:31 +08:00
parent accd28c9a1
commit f6b399e03e
23 changed files with 3682 additions and 225 deletions

View file

@ -118,5 +118,8 @@ class CliTargetOut(BaseModel):
ne_type: str = "" ne_type: str = ""
vendor: str = "" vendor: str = ""
device_type: str = "" device_type: str = ""
protocol: str = ""
username: str = ""
has_password: bool = False
connect_status: str = "unknown" connect_status: str = "unknown"
cli_profile_ready: bool = False cli_profile_ready: bool = False

View file

@ -258,8 +258,10 @@ def list_cli_targets(
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
) -> dict[str, Any]: ) -> dict[str, Any]:
from .device_types import WEBCRT_NE_SOURCE
src = str(source or "all").strip().lower() 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") raise HTTPException(status_code=400, detail="invalid_source")
ready = cli_profile_ready(db) ready = cli_profile_ready(db)
page = max(1, int(page or 1)) page = max(1, int(page or 1))
@ -267,15 +269,18 @@ def list_cli_targets(
offset = (page - 1) * page_size offset = (page - 1) * page_size
kw = str(keyword or "").strip() 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( return CliTargetOut(
source="managed", source=list_source,
id=str(row.id), id=str(row.id),
ume_ne_id=None, ume_ne_id=None,
name=str(row.name or row.ip_address), name=str(row.name or row.ip_address),
ip_address=str(row.ip_address), ip_address=str(row.ip_address),
vendor=str(row.vendor), vendor=str(row.vendor),
device_type=str(row.device_type), 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), connect_status=str(row.connect_status),
cli_profile_ready=True, cli_profile_ready=True,
).model_dump() ).model_dump()
@ -293,19 +298,27 @@ def list_cli_targets(
cli_profile_ready=ready, cli_profile_ready=ready,
).model_dump() ).model_dump()
def _managed_query(): def _apply_kw(stmt: Any) -> Any:
stmt = db.query(ManagedNE) if not kw:
if kw: return stmt
like = f"%{kw}%" like = f"%{kw}%"
stmt = stmt.filter( return stmt.filter(
ManagedNE.name.ilike(like) ManagedNE.name.ilike(like)
| ManagedNE.ip_address.ilike(like) | ManagedNE.ip_address.ilike(like)
| ManagedNE.username.ilike(like) | ManagedNE.username.ilike(like)
| ManagedNE.tags.ilike(like) | ManagedNE.tags.ilike(like)
| ManagedNE.vendor.ilike(like) | ManagedNE.vendor.ilike(like)
| ManagedNE.device_type.ilike(like) | ManagedNE.device_type.ilike(like)
) )
return stmt.order_by(ManagedNE.updated_at.desc())
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(): def _ume_query():
stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin( stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin(
@ -323,10 +336,17 @@ def list_cli_targets(
return stmt.order_by(UmeInventoryNE.ne_id.asc()) return stmt.order_by(UmeInventoryNE.ne_id.asc())
if src == "managed": if src == "managed":
mq = _managed_query() mq = _inventory_managed_query()
total = int(mq.count()) total = int(mq.count())
rows = mq.offset(offset).limit(page_size).all() 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} return {"items": items, "total": total, "page": page, "page_size": page_size}
if src == "ume": if src == "ume":
@ -336,24 +356,40 @@ def list_cli_targets(
items = [_ume_item(inv, ov) for inv, ov in rows] items = [_ume_item(inv, ov) for inv, ov in rows]
return {"items": items, "total": total, "page": page, "page_size": page_size} return {"items": items, "total": total, "page": page, "page_size": page_size}
# source=all: managed first, then UME, with correct cross-list pagination # source=all: inventory managed → webcrt sessions → UME
mq = _managed_query() mq = _inventory_managed_query()
wq = _webcrt_query()
uq = _ume_query() uq = _ume_query()
m_total = int(mq.count()) m_total = int(mq.count())
w_total = int(wq.count())
u_total = int(uq.count()) u_total = int(uq.count())
total = m_total + u_total total = m_total + w_total + u_total
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
if offset < m_total:
take = min(page_size, m_total - offset) remaining = page_size
for row in mq.offset(offset).limit(take).all(): cursor = offset
items.append(_managed_item(row))
need = page_size - len(items) if cursor < m_total and remaining > 0:
if need > 0 and u_total > 0: take = min(remaining, m_total - cursor)
for inv, ov in uq.offset(0).limit(need).all(): for row in mq.offset(cursor).limit(take).all():
items.append(_ume_item(inv, ov)) items.append(_managed_item(row, list_source="managed"))
remaining -= take
cursor = 0
else: else:
u_off = offset - m_total cursor = max(0, cursor - m_total)
for inv, ov in uq.offset(u_off).limit(page_size).all():
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)) items.append(_ume_item(inv, ov))
return {"items": items, "total": total, "page": page, "page_size": page_size} return {"items": items, "total": total, "page": page, "page_size": page_size}

View file

@ -88,8 +88,8 @@ class Settings(BaseSettings):
webcrt_connect_timeout_sec: int = 90 webcrt_connect_timeout_sec: int = 90
webcrt_attach_timeout_sec: int = 60 webcrt_attach_timeout_sec: int = 60
webcrt_data_dir: str = "data/webcrt" webcrt_data_dir: str = "data/webcrt"
# SSH transport keepalive interval (seconds); 0 disables. # SSH transport keepalive interval (seconds); 0 disables (default off).
webcrt_keepalive_sec: int = 30 webcrt_keepalive_sec: int = 0
# Device anti-idle CLI nudge (0 = off). Keep off: NEs close idle VTY themselves. # Device anti-idle CLI nudge (0 = off). Keep off: NEs close idle VTY themselves.
webcrt_anti_idle_sec: int = 0 webcrt_anti_idle_sec: int = 0
webcrt_anti_idle_payload: str = " " webcrt_anti_idle_payload: str = " "

View file

@ -43,3 +43,9 @@ SUPPORTED_VENDORS: tuple[str, ...] = (
"Nokia", "Nokia",
"Other", "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"

View file

@ -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 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 source_ref VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT 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( conn.exec_driver_sql(
"ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP" "ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP"
) )

View file

@ -278,7 +278,9 @@ class ManagedNE(Base):
name: Mapped[str] = mapped_column(String(256), default="", index=True) name: Mapped[str] = mapped_column(String(256), default="", index=True)
vendor: Mapped[str] = mapped_column(String(64), default="Other", index=True) vendor: Mapped[str] = mapped_column(String(64), default="Other", index=True)
device_type: Mapped[str] = mapped_column(String(128), default="") 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) port: Mapped[int] = mapped_column(Integer, default=22)
protocol: Mapped[str] = mapped_column(String(16), default="ssh") protocol: Mapped[str] = mapped_column(String(16), default="ssh")
username: Mapped[str] = mapped_column(String(128), default="") username: Mapped[str] = mapped_column(String(128), default="")

View file

@ -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.""" """Human/ops-facing failure message; promote auth rejects above Pattern/ReadTimeout."""
if isinstance(exc, BaseException): if isinstance(exc, BaseException):
exc_text = f"{type(exc).__name__}: {exc}" 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: else:
exc_text = str(exc or "") exc_text = str(exc or "")
combined = f"{exc_text}\n{transcript or ''}" combined = f"{exc_text}\n{transcript or ''}"

View file

@ -8,13 +8,22 @@ from typing import Any
def normalize_netmiko_device_type(device_type: str, protocol: str) -> str: def normalize_netmiko_device_type(device_type: str, protocol: str) -> str:
dt = str(device_type or "").strip() dt = str(device_type or "").strip()
proto = str(protocol or "ssh").strip().lower() 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": if dt == "zte":
return f"zte_zxros_{proto}" 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 f"{dt}_{proto}"
return dt 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 f"{dt}_{proto}"
return dt return dt

View file

@ -10,7 +10,12 @@ from fastapi import HTTPException
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session 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 .models import ManagedNE, UmeInventoryNE
from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_secret, encrypt_secret from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_secret, encrypt_secret
from .ne_schemas import ( from .ne_schemas import (
@ -41,6 +46,10 @@ IMPORT_COLUMNS = (
UME_SYNC_SOURCE = "ume_sync" UME_SYNC_SOURCE = "ume_sync"
UME_SYNC_TAG = "UME" 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]] = [ _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"ZXR|ZXCTN|M6000|\bBN\b", re.I), "zte_zxros", "ZTE"),
(re.compile(r"NE40|CE\b|ATN|MA5800|OptiX", re.I), "huawei", "Huawei"), (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) 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: def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> ManagedNeOut:
row = db.get(ManagedNE, ne_id) row = db.get(ManagedNE, ne_id)
if not row: if not row:

View file

@ -243,9 +243,10 @@ def _netmiko_driver_class(device_type: str) -> type:
def _interactive_driver_class(base_cls: type) -> 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] 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 def set_terminal_width(self, *args: Any, **kwargs: Any) -> str: # noqa: ANN401
return "" 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')}" _InteractiveSession.__name__ = f"Interactive{getattr(base_cls, '__name__', 'Netmiko')}"
return _InteractiveSession return _InteractiveSession

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect 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) cols: int = Field(default=80, ge=20, le=500)
rows: int = Field(default=24, ge=5, le=200) rows: int = Field(default=24, ge=5, le=200)
encoding: str = Field(default="utf-8") 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) 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. # Default async so UI can open WS while connect runs; tests may force sync via service API.
async_connect: bool = Field(default=True) 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): class WebcrtSftpListBody(BaseModel):
@ -68,6 +92,13 @@ def api_list_sessions() -> dict[str, Any]:
return list_sessions() 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") @router.post("/sessions")
def api_create_session( def api_create_session(
body: WebcrtSessionCreate, body: WebcrtSessionCreate,
@ -86,11 +117,82 @@ def api_create_session(
rows=body.rows, rows=body.rows,
client=_client_label(request=request), client=_client_label(request=request),
encoding=body.encoding, encoding=body.encoding,
keepalive_sec=body.keepalive_sec,
post_login_commands=list(body.post_login_commands or [])[:20], post_login_commands=list(body.post_login_commands or [])[:20],
async_connect=bool(body.async_connect), 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}") @router.delete("/sessions/{session_id}")
def api_close_session(session_id: str, request: Request) -> dict[str, Any]: 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)) 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", "type": "status",
"state": "connecting" if sess.state == "connecting" else "connected", "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, "session_id": sess.session_id,
"ne_id": sess.ne_id, "ne_id": sess.ne_id,
"ne_name": sess.ne_name, "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": if sess.state == "connecting":
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
try: budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15
await loop.run_in_executor( deadline = time.time() + budget
None, while True:
lambda: wait_session_ready( cur = get_session(session_id) or sess
session_id, if cur.state != "connecting":
timeout=max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15, sess = cur
), break
) elapsed = max(0.0, time.time() - float(cur.connect_started_at or time.time()))
sess = get_session(session_id) or sess phase = "authenticating" if elapsed < 6.0 else "waiting_prompt"
except HTTPException as exc: try:
await websocket.send_json( await websocket.send_json(
{"type": "status", "state": "error", "message": str(exc.detail)} {
) "type": "status",
await websocket.close(code=4502) "state": "connecting",
return "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( await websocket.send_json(
{ {

View file

@ -64,7 +64,7 @@ def uses_network_cli_keymap(device_type: str = "", vendor: str = "") -> bool:
blob = f"{device_type} {vendor}".strip().lower() blob = f"{device_type} {vendor}".strip().lower()
if not blob: if not blob:
return True 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: if token in blob:
return False return False
return True return True
@ -158,10 +158,35 @@ def _looks_like_cli_prompt(text: str) -> bool:
s = str(text or "").rstrip() s = str(text or "").rstrip()
if not s: if not s:
return False return False
# Buffer races can leave a stray ':' after Huawei ``<r1>`` (from prior ``[Y/N]:``).
if s.endswith(":") and ">" in s:
s = s[:-1].rstrip()
# Common network CLI prompts: <r1> [HUAWEI] Router# Router> # Common network CLI prompts: <r1> [HUAWEI] Router# Router>
return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s)) 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. # 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})[#>])") _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 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. 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 ``<host>`` 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: def _capture_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> str:
"""Discard leftover bytes on the live channel (SSH/Telnet) after login priming.""" """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) channel = getattr(conn, "remote_conn", None)
if channel is None: if channel is None:
try: 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: except Exception:
pass return ""
return
end = time.time() + max(0.1, float(duration)) end = time.time() + max(0.1, float(duration))
while time.time() < end: while time.time() < end:
got = False got = False
try: try:
if hasattr(channel, "recv_ready") and hasattr(channel, "recv") and channel.recv_ready(): # Paramiko SSH channel
channel.recv(65535) if hasattr(channel, "recv_ready") and hasattr(channel, "recv"):
got = True if channel.recv_ready():
elif hasattr(channel, "read_very_eager"): 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() data = channel.read_very_eager()
if data: if data:
got = True got = True
if isinstance(data, bytes):
chunks.append(data.decode("utf-8", errors="replace"))
else:
chunks.append(str(data))
else: else:
part = conn.read_channel() part = conn.read_channel()
if part: if part:
got = True got = True
chunks.append(str(part))
except Exception: except Exception:
break break
if not got: if not got:
time.sleep(0.04) time.sleep(0.04)
return "".join(chunks)
def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool = False) -> None: def _drain_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> None:
"""Send one RETURN after Netmiko login so the interactive channel is fully ready. """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 — 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. otherwise slow Cisco VMs accumulate duplicate ``R2#`` lines in the bootstrap.
""" """
parts: list[str] = []
try: try:
_drain_raw_channel(conn, duration=0.15) parts.append(_capture_raw_channel(conn, duration=0.25))
except Exception: except Exception:
pass pass
if not already_prompted: if not already_prompted:
@ -238,15 +290,16 @@ def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool =
try: try:
conn.write_channel("\n") conn.write_channel("\n")
except Exception: except Exception:
return return "".join(parts)
try: try:
_drain_channel(conn, rounds=6, wait=0.08) parts.append(_drain_channel(conn, rounds=6, wait=0.08))
except Exception: except Exception:
pass pass
try: try:
_drain_raw_channel(conn, duration=0.35) parts.append(_capture_raw_channel(conn, duration=0.35))
except Exception: except Exception:
pass pass
return "".join(parts)
def _is_prompt_only_echo(text: str, prompt_hint: str = "") -> bool: def _is_prompt_only_echo(text: str, prompt_hint: str = "") -> bool:
@ -361,6 +414,7 @@ class WebcrtSession:
vendor: str = "" vendor: str = ""
cli_keymap: bool = True cli_keymap: bool = True
encoding: str = "utf-8" encoding: str = "utf-8"
keepalive_sec: int = 0
conn: ConnectHandler | None = None conn: ConnectHandler | None = None
created_at: float = field(default_factory=time.time) created_at: float = field(default_factory=time.time)
last_activity: 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 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 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_enabled = bool(creds.get("hop_enabled"))
hop_vendor = str(creds.get("hop_vendor") or "").strip().lower() hop_vendor = str(creds.get("hop_vendor") or "").strip().lower()
auth_mode = str(creds.get("hop_target_auth_mode") or "bastion_managed").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": if hop_enabled and hop_vendor == "bastion" and auth_mode == "bastion_managed":
return bool( return bool(
str(creds.get("hop_host") or "").strip() str(creds.get("hop_host") or "").strip()
and str(creds.get("hop_username") or "").strip() and str(creds.get("hop_username") or "").strip()
and str(creds.get("hop_password") or "") 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 "")) return bool(str(creds.get("password") or ""))
@ -835,6 +895,7 @@ def _finish_connect(
cols=sess.cols, cols=sess.cols,
rows=sess.rows, rows=sess.rows,
interactive=True, interactive=True,
keepalive=int(sess.keepalive_sec or 0),
) )
except Exception as exc: except Exception as exc:
partial = _session_log_text(log_buf).strip() partial = _session_log_text(log_buf).strip()
@ -868,29 +929,57 @@ def _finish_connect(
pass pass
pre_log = _session_log_text(log_buf) pre_log = _session_log_text(log_buf)
already_prompted = _looks_like_cli_prompt(pre_log) # Pull post-auth banner/MOTD from the PTY. With interactive no-op session_preparation
_prime_interactive_channel(conn, already_prompted=already_prompted) # (generic_termserver), Netmiko session_log is often empty — do not discard these bytes.
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.
try: 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: except Exception:
pass 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 ``<r1>`` 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) hop_guard = get_cli_hop_guard(conn)
sess.conn = conn sess.conn = conn
sess.cli_hop_guard = bool(hop_guard) sess.cli_hop_guard = bool(hop_guard)
sess.cli_hop_prompt = str((hop_guard or {}).get("hop_prompt") or "") sess.cli_hop_prompt = str((hop_guard or {}).get("hop_prompt") or "")
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding) sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
# Bootstrap already ends at a prompt → never nudge another Enter on WS attach. # Nudge Enter on WS attach only when we still need a shell prompt.
sess.needs_live_prompt = not _looks_like_cli_prompt(bootstrap) # 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() sess.open_session_log()
if bootstrap: if bootstrap:
sess.append_session_log(bootstrap if bootstrap.endswith("\n") else bootstrap + "\n") sess.append_session_log(bootstrap if bootstrap.endswith("\n") else bootstrap + "\n")
@ -954,8 +1043,11 @@ def create_session(
rows: int = 24, rows: int = 24,
client: str = "", client: str = "",
encoding: str = "utf-8", encoding: str = "utf-8",
keepalive_sec: int | None = None,
post_login_commands: list[str] | None = None, post_login_commands: list[str] | None = None,
async_connect: bool = True, async_connect: bool = True,
username_override: str | None = None,
password_override: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from .cli_resolve import resolve_cli_target from .cli_resolve import resolve_cli_target
@ -975,6 +1067,19 @@ def create_session(
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=400, detail=f"credential_error:{exc}") from 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): if not _webcrt_creds_ready(creds):
raise HTTPException(status_code=400, detail="credentials_incomplete") 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_id = str(device.get("id") or mid or uid)
target_ip = str(device.get("ip_address") or "") target_ip = str(device.get("ip_address") or "")
target_name = str(device.get("name") or target_ip) 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 "") device_type = str(device.get("device_type") or creds.get("device_type") or "")
vendor = str(device.get("vendor") or creds.get("vendor") or "") vendor = str(device.get("vendor") or creds.get("vendor") or "")
cli_keymap = uses_network_cli_keymap(device_type, vendor) cli_keymap = uses_network_cli_keymap(device_type, vendor)
enc = _normalize_encoding(encoding) 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( sess = WebcrtSession(
session_id=session_id, session_id=session_id,
@ -1003,6 +1111,7 @@ def create_session(
vendor=vendor, vendor=vendor,
cli_keymap=cli_keymap, cli_keymap=cli_keymap,
encoding=enc, encoding=enc,
keepalive_sec=ka,
state="connecting", state="connecting",
post_login_commands=list(post_login_commands or [])[:20], post_login_commands=list(post_login_commands or [])[:20],
) )
@ -1057,6 +1166,7 @@ def create_session(
"cols": sess.cols, "cols": sess.cols,
"rows": sess.rows, "rows": sess.rows,
"encoding": enc, "encoding": enc,
"keepalive_sec": ka,
"state": sess.state, "state": sess.state,
"ws_path": f"/v1/webcrt/sessions/{session_id}/ws", "ws_path": f"/v1/webcrt/sessions/{session_id}/ws",
"cli_hop": bool(sess.cli_hop_guard), "cli_hop": bool(sess.cli_hop_guard),
@ -1153,6 +1263,7 @@ def list_sessions() -> dict[str, Any]:
"ne_ip": s.ne_ip, "ne_ip": s.ne_ip,
"protocol": s.protocol, "protocol": s.protocol,
"encoding": s.encoding, "encoding": s.encoding,
"keepalive_sec": int(s.keepalive_sec or 0),
"state": s.state, "state": s.state,
"attached": s.attached, "attached": s.attached,
"created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(), "created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(),
@ -1174,7 +1285,7 @@ def list_sessions() -> dict[str, Any]:
"total": len(items), "total": len(items),
"max_sessions": max(1, int(settings.webcrt_max_sessions or 20)), "max_sessions": max(1, int(settings.webcrt_max_sessions or 20)),
"idle_timeout_sec": max(60, int(settings.webcrt_idle_timeout_sec or 1800)), "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), "anti_idle_sec": int(getattr(settings, "webcrt_anti_idle_sec", 0) or 0),
"items": items, "items": items,
} }

View file

@ -12,10 +12,18 @@ from sqlalchemy.pool import StaticPool
from netx_api.config import settings from netx_api.config import settings
from netx_api.db import Base, get_db from netx_api.db import Base, get_db
from netx_api.main import app 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_connect import hostname_probe_command, parse_hostname_from_output
from netx_api.ne_crypto import decrypt_secret, encrypt_secret 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 from netx_api.ne_schemas import ManagedNeCreate
@ -319,5 +327,182 @@ class ManagedNeCreateOptionalPasswordTests(unittest.TestCase):
self.assertEqual(row.password_enc, "") 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -33,6 +33,13 @@ class CliAuthClassifyTests(unittest.TestCase):
msg = format_cli_failure(AuthenticationException("target_auth_rejected: Permission denied")) msg = format_cli_failure(AuthenticationException("target_auth_rejected: Permission denied"))
self.assertTrue(msg.startswith("auth_rejected:")) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -101,6 +101,119 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertEqual(svc.prepare_bootstrap_output("banner\nR2#\n\nR2#"), "banner\nR2#") 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.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.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("<r1>"))
self.assertTrue(svc._looks_like_cli_prompt("<r1>"))
# Stray ':' after Huawei prompt must still count as prompted (no extra Enter).
self.assertTrue(svc._looks_like_cli_prompt("<r1>:"))
self.assertEqual(svc.prepare_bootstrap_output("banner\n<r1>:"), "banner\n<r1>")
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, "_audit")
@patch.object(svc, "open_netmiko_connection") @patch.object(svc, "open_netmiko_connection")
@ -190,6 +303,8 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertTrue(called_creds["hop_enabled"]) self.assertTrue(called_creds["hop_enabled"])
self.assertEqual(called_creds["hop_vendor"], "bastion") self.assertEqual(called_creds["hop_vendor"], "bastion")
self.assertIn("session_log", mock_open.call_args.kwargs) 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() fake.remote_conn.resize_pty.assert_called()
self.assertEqual(out["ne_id"], "ne-hop") self.assertEqual(out["ne_id"], "ne-hop")
self.assertFalse(out.get("cli_hop")) # bastion hop is not vendor CLI hop guard self.assertFalse(out.get("cli_hop")) # bastion hop is not vendor CLI hop guard
@ -320,6 +435,17 @@ class WebcrtServiceTests(unittest.TestCase):
{ {
"username": "u", "username": "u",
"password": "", "password": "",
"protocol": "ssh",
"hop_enabled": False,
}
)
)
self.assertTrue(
svc._webcrt_creds_ready(
{
"username": "",
"password": "",
"protocol": "telnet",
"hop_enabled": False, "hop_enabled": False,
} }
) )
@ -495,6 +621,17 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertEqual(svc._normalize_encoding("utf8"), "utf-8") self.assertEqual(svc._normalize_encoding("utf8"), "utf-8")
self.assertEqual(svc._encode_text("测", "gbk")[:1], b"\xb2") self.assertEqual(svc._encode_text("测", "gbk")[:1], b"\xb2")
def test_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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -2,6 +2,10 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "re
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import {
applyKeywordHighlight,
type KeywordHighlightConfig,
} from "../utils/webcrtKeywordHighlight";
export type WebTerminalHandle = { export type WebTerminalHandle = {
clear: () => void; clear: () => void;
@ -16,15 +20,55 @@ export type WebTerminalHandle = {
findPrevious: (term: string) => void; 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 = { type Props = {
wsUrl: string; wsUrl: string;
title?: string; title?: string;
recording?: boolean; recording?: boolean;
autoFocus?: boolean; autoFocus?: boolean;
encoding?: string; encoding?: string;
fontSize?: number;
/** Terminal background / foreground (overrides legacy themeName). */
termColors?: TermColors;
/** @deprecated use termColors */
themeName?: "dark" | "light";
pasteDelayMs?: number; pasteDelayMs?: number;
copyOnSelect?: boolean; copyOnSelect?: boolean;
onStatus?: (state: string, message?: string) => void; keywordHighlight?: KeywordHighlightConfig;
onStatus?: (state: string, message?: string, phase?: string) => void;
onReady?: () => void; onReady?: () => void;
onStdout?: (data: string) => void; onStdout?: (data: string) => void;
}; };
@ -103,14 +147,22 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
recording, recording,
autoFocus = true, autoFocus = true,
encoding = "utf-8", encoding = "utf-8",
fontSize = 13,
termColors,
themeName = "dark",
pasteDelayMs, pasteDelayMs,
copyOnSelect, copyOnSelect,
keywordHighlight,
onStatus, onStatus,
onReady, onReady,
onStdout, onStdout,
}, },
ref, ref,
) { ) {
const resolvedColors: TermColors = termColors ||
(themeName === "light"
? { background: "#ffffff", foreground: "#000000" }
: DEFAULT_TERM_COLORS);
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null); const fitRef = useRef<FitAddon | null>(null);
@ -122,9 +174,14 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const recordingRef = useRef(!!recording); const recordingRef = useRef(!!recording);
const autoFocusRef = useRef(autoFocus); const autoFocusRef = useRef(autoFocus);
const encodingRef = useRef(encoding); const encodingRef = useRef(encoding);
const fontSizeRef = useRef(Math.max(10, Math.min(28, Number(fontSize) || 13)));
const termColorsRef = useRef<TermColors>(resolvedColors);
const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs); const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs);
const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect); const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect);
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve()); const pasteQueueRef = useRef<Promise<void>>(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 [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [findOpen, setFindOpen] = useState(false); const [findOpen, setFindOpen] = useState(false);
const [findQuery, setFindQuery] = useState(""); const [findQuery, setFindQuery] = useState("");
@ -149,6 +206,25 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
encodingRef.current = encoding; encodingRef.current = encoding;
}, [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(() => { useEffect(() => {
if (pasteDelayMs != null) pasteDelayRef.current = pasteDelayMs; if (pasteDelayMs != null) pasteDelayRef.current = pasteDelayMs;
}, [pasteDelayMs]); }, [pasteDelayMs]);
@ -157,6 +233,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect; if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect;
}, [copyOnSelect]); }, [copyOnSelect]);
keywordHighlightRef.current = keywordHighlight;
const focusTerminal = () => { const focusTerminal = () => {
try { try {
termRef.current?.focus(); termRef.current?.focus();
@ -177,11 +255,37 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
sendJson({ type: "stdin", data }); 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<void>((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) => { const sendStdinThrottled = (data: string) => {
if (!data) return; if (!data) return;
const delay = pasteDelayRef.current; const maxDelay = pasteDelayRef.current;
if (delay <= 0 || data.length < 8) { if (maxDelay <= 0 || data.length < 8) {
sendStdinImmediate(data); sendStdinImmediate(data);
return; return;
} }
@ -193,10 +297,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
for (let i = 0; i < lines.length; i += 1) { for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]; const line = lines[i];
const chunk = i < lines.length - 1 ? `${line}\r` : line; const chunk = i < lines.length - 1 ? `${line}\r` : line;
const sentAt = performance.now();
if (chunk) sendStdinImmediate(chunk); if (chunk) sendStdinImmediate(chunk);
setPasteStatus({ done: i + 1, total }); setPasteStatus({ done: i + 1, total });
if (i < lines.length - 1) { if (i < lines.length - 1) {
await new Promise((r) => window.setTimeout(r, delay)); await waitForEchoOrTimeout(maxDelay, sentAt);
} }
} }
} finally { } finally {
@ -274,15 +379,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
const term = new Terminal({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: 13, fontSize: fontSizeRef.current,
fontFamily: 'Consolas, "Courier New", monospace', fontFamily: 'Consolas, "Courier New", monospace',
scrollback: 10000, scrollback: 10000,
theme: { theme: xtermThemeFromColors(termColorsRef.current),
background: "#0b1220",
foreground: "#e2e8f0",
cursor: "#e2e8f0",
selectionBackground: "#334155",
},
convertEol: true, convertEol: true,
}); });
const fit = new FitAddon(); const fit = new FitAddon();
@ -312,22 +412,31 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}, 50); }, 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); const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
wsRef.current = ws; wsRef.current = ws;
onStatusRef.current?.("connecting"); onStatusRef.current?.("connecting");
const isActiveSocket = () => !cancelled && wsRef.current === ws;
const writeStdout = (raw: string) => { const writeStdout = (raw: string) => {
if (raw) term.write(raw); if (!raw || !isActiveSocket()) return;
lastStdoutAtRef.current = performance.now();
if (recordingRef.current) onStdoutRef.current?.(raw); if (recordingRef.current) onStdoutRef.current?.(raw);
term.write(applyKeywordHighlight(raw, keywordHighlightRef.current));
}; };
const sendResize = () => { const sendResize = () => {
if (!isActiveSocket()) return;
doFit(); doFit();
sendJson({ type: "resize", cols: term.cols, rows: term.rows }); sendJson({ type: "resize", cols: term.cols, rows: term.rows });
}; };
ws.onopen = () => { ws.onopen = () => {
if (!isActiveSocket()) return;
onStatusRef.current?.("open"); onStatusRef.current?.("open");
sendResize(); sendResize();
onReadyRef.current?.(); onReadyRef.current?.();
@ -335,6 +444,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}; };
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
if (!isActiveSocket()) return;
if (ev.data instanceof ArrayBuffer) { if (ev.data instanceof ArrayBuffer) {
writeStdout(decodeBytes(ev.data, encodingRef.current)); writeStdout(decodeBytes(ev.data, encodingRef.current));
maybeFocus(); maybeFocus();
@ -342,6 +452,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
} }
if (typeof Blob !== "undefined" && ev.data instanceof Blob) { if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
void ev.data.arrayBuffer().then((buf) => { void ev.data.arrayBuffer().then((buf) => {
if (!isActiveSocket()) return;
writeStdout(decodeBytes(buf, encodingRef.current)); writeStdout(decodeBytes(buf, encodingRef.current));
maybeFocus(); maybeFocus();
}); });
@ -353,6 +464,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
data?: string; data?: string;
state?: string; state?: string;
message?: string; message?: string;
phase?: string;
}; };
if (msg.type === "stdout" && typeof msg.data === "string") { if (msg.type === "stdout" && typeof msg.data === "string") {
writeStdout(msg.data); writeStdout(msg.data);
@ -360,7 +472,9 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
return; return;
} }
if (msg.type === "status") { 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") { if (msg.state === "connected" || msg.state === "connecting") {
maybeFocus(); maybeFocus();
return; return;
@ -378,11 +492,14 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}; };
ws.onerror = () => { ws.onerror = () => {
if (!isActiveSocket()) return;
onStatusRef.current?.("error", "websocket_error"); onStatusRef.current?.("error", "websocket_error");
term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m"); term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m");
}; };
ws.onclose = (ev) => { 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}`); onStatusRef.current?.("closed", `websocket_closed:${ev.code}`);
if (!ev.wasClean) { if (!ev.wasClean) {
term.writeln(`\r\n\x1b[33m[websocket closed code=${ev.code}]\x1b[0m`); term.writeln(`\r\n\x1b[33m[websocket closed code=${ev.code}]\x1b[0m`);
@ -475,6 +592,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
ro?.observe(host); ro?.observe(host);
return () => { return () => {
cancelled = true;
window.clearInterval(pingTimer); window.clearInterval(pingTimer);
window.removeEventListener("resize", onWinResize); window.removeEventListener("resize", onWinResize);
window.removeEventListener("keydown", onKeyDownCapture, true); window.removeEventListener("keydown", onKeyDownCapture, true);
@ -483,17 +601,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
ro?.disconnect(); ro?.disconnect();
dataDisposable.dispose(); dataDisposable.dispose();
selDisposable.dispose(); selDisposable.dispose();
if (wsRef.current === ws) wsRef.current = null;
try { try {
ws.close(); ws.close();
} catch { } catch {
/* ignore */ /* ignore */
} }
wsRef.current = null;
term.dispose(); term.dispose();
termRef.current = null; termRef.current = null;
fitRef.current = null; fitRef.current = null;
}; };
}, [wsUrl, title]); // title is display-only; remounting on title change tears down a live WS.
}, [wsUrl]);
const pasteFromClipboard = async () => { const pasteFromClipboard = async () => {
try { try {

View file

@ -905,7 +905,7 @@ const en = {
sessionManager: "Device list", sessionManager: "Device list",
connect: "Connect", connect: "Connect",
hint: "Pick an NE and open an interactive terminal in the browser. Credentials stay on the server.", 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", filterKeyword: "Filter",
filterKeywordPh: "Filter by name / IP / tag", filterKeywordPh: "Filter by name / IP / tag",
empty: "No matching NEs. Switch source or refine the search.", empty: "No matching NEs. Switch source or refine the search.",
@ -919,8 +919,25 @@ const en = {
source: { source: {
all: "All", all: "All",
managed: "Managed", managed: "Managed",
webcrt: "Sessions",
ume: "UME", 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: { status: {
idle: "Idle", idle: "Idle",
connecting: "Connecting", connecting: "Connecting",
@ -928,6 +945,47 @@ const en = {
error: "Error", error: "Error",
closed: "Closed", 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: { actions: {
reconnect: "Reconnect", reconnect: "Reconnect",
recordLog: "Record log", recordLog: "Record log",
@ -949,7 +1007,34 @@ const en = {
recent: "Recent", recent: "Recent",
favorite: "Toggle favorite", favorite: "Toggle favorite",
encoding: "Encoding", 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…", broadcastPh: "Send to all tabs…",
buttonBarHint: "One per line: label|command", buttonBarHint: "One per line: label|command",
postLoginPrompt: "Post-login commands (one per line, this device)", postLoginPrompt: "Post-login commands (one per line, this device)",
@ -960,9 +1045,12 @@ const en = {
uploaded: "Upload complete", uploaded: "Upload complete",
}, },
err: { 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", sessionLimit: "Session limit reached; close other terminals first",
neNotFound: "Network element not found", neNotFound: "Network element not found",
credsIncomplete: "Incomplete username/password on this NE", 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)", cliProfile: "UME NEs require a CLI connect profile (default account / hop)",
connectFailed: "Device login failed: {{detail}}", connectFailed: "Device login failed: {{detail}}",
sftpHop: "SFTP does not support hop devices yet (direct SSH only)", sftpHop: "SFTP does not support hop devices yet (direct SSH only)",

View file

@ -902,7 +902,7 @@ const zh = {
sessionManager: "设备列表", sessionManager: "设备列表",
connect: "连接", connect: "连接",
hint: "选择网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。", hint: "选择网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。",
hintCrt: "在左侧点击网元即可打开终端;支持托管与 UME 网元,可用搜索与分页浏览。", hintCrt: "在左侧点击网元即可打开终端;支持托管、会话与 UME;也可用菜单「新建会话」快速登录。",
filterKeyword: "筛选", filterKeyword: "筛选",
filterKeywordPh: "按名称 / IP / 标签筛选", filterKeywordPh: "按名称 / IP / 标签筛选",
empty: "没有匹配的网元。可切换来源或调整搜索关键字。", empty: "没有匹配的网元。可切换来源或调整搜索关键字。",
@ -916,8 +916,25 @@ const zh = {
source: { source: {
all: "全部", all: "全部",
managed: "托管", managed: "托管",
webcrt: "会话",
ume: "UME", 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: { status: {
idle: "空闲", idle: "空闲",
connecting: "连接中", connecting: "连接中",
@ -925,6 +942,47 @@ const zh = {
error: "错误", error: "错误",
closed: "已关闭", 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: { actions: {
reconnect: "重新连接", reconnect: "重新连接",
recordLog: "记录日志", recordLog: "记录日志",
@ -947,6 +1005,33 @@ const zh = {
favorite: "收藏/取消", favorite: "收藏/取消",
encoding: "编码", encoding: "编码",
fontSize: "字号", 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: "发送到全部标签…", broadcastPh: "发送到全部标签…",
buttonBarHint: "每行:按钮名|命令", buttonBarHint: "每行:按钮名|命令",
postLoginPrompt: "登录后自动执行(每行一条,仅本设备)", postLoginPrompt: "登录后自动执行(每行一条,仅本设备)",
@ -957,9 +1042,12 @@ const zh = {
uploaded: "上传成功", uploaded: "上传成功",
}, },
err: { err: {
deviceType: "不支持的设备类型",
ipConflictRestart: "数据库仍限制 IP 唯一,请重启 netx API 后再试",
sessionLimit: "并发会话已达上限,请先关闭其他终端", sessionLimit: "并发会话已达上限,请先关闭其他终端",
neNotFound: "网元不存在", neNotFound: "网元不存在",
credsIncomplete: "网元账号或密码不完整", credsIncomplete: "网元账号或密码不完整",
authRejected: "用户名或密码错误,请重新输入",
cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)", cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)",
connectFailed: "登录设备失败:{{detail}}", connectFailed: "登录设备失败:{{detail}}",
sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH", sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH",

View file

@ -1883,20 +1883,354 @@ pre {
letter-spacing: -0.01em; 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 { .webcrt-sidebar__toolbar {
display: none; display: none;
} }
.webcrt-sidebar__search { .webcrt-sidebar__chrome .webcrt-sidebar__search {
padding: 8px 10px; padding: 0;
border-bottom: 1px solid #d7dee8; border-bottom: 0;
flex-shrink: 0; flex-shrink: 0;
} }
.webcrt-sidebar__search input { .webcrt-sidebar__search input {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
height: 32px; height: 30px;
padding: 0 10px; padding: 0 10px;
border: 1px solid #b8c4d2; border: 1px solid #b8c4d2;
border-radius: 6px; border-radius: 6px;
@ -1904,11 +2238,15 @@ pre {
font-size: 12px; font-size: 12px;
} }
.webcrt-sidebar__chrome .webcrt-source-tabs {
margin: 0;
}
.webcrt-source-tabs { .webcrt-source-tabs {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 2px; gap: 2px;
margin: 0 10px 8px; margin: 0;
padding: 2px; padding: 2px;
background: #d7dee8; background: #d7dee8;
border: 1px solid var(--nm-nav-border); border: 1px solid var(--nm-nav-border);
@ -1916,21 +2254,41 @@ pre {
flex-shrink: 0; 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 { .webcrt-source-tabs__btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 28px; min-width: 0;
padding: 0 6px; height: 26px;
padding: 0 2px;
border: 0; border: 0;
border-radius: 4px; border-radius: 4px;
background: transparent; background: transparent;
color: var(--nm-muted); color: var(--nm-muted);
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
box-shadow: none; box-shadow: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.app-main .webcrt-source-tabs__btn, .app-main .webcrt-source-tabs__btn,
@ -1996,7 +2354,7 @@ pre {
background: transparent; background: transparent;
min-height: 0; min-height: 0;
overscroll-behavior: contain; overscroll-behavior: contain;
padding: 0 8px 8px; padding: 8px 10px;
} }
.webcrt-tree__folder { .webcrt-tree__folder {
@ -2202,6 +2560,20 @@ pre {
box-shadow: 0 1px 0 #fff; 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 */ /* span close mark — avoid global `button` border/padding chrome */
.webcrt-tabs__close { .webcrt-tabs__close {
display: inline; display: inline;
@ -2271,6 +2643,83 @@ pre {
color: var(--nm-accent); 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 { .webcrt-main__body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@ -2290,13 +2739,33 @@ pre {
} }
.webcrt-main__pane .webcrt-term, .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; flex: 1;
width: 100%; width: 100%;
height: auto !important; height: auto !important;
min-height: 240px !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__empty,
.webcrt-main__placeholder { .webcrt-main__placeholder {
height: 100%; height: 100%;
@ -2415,6 +2884,20 @@ pre {
background: #334155; 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 { .webcrt-tree__row {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
@ -2625,10 +3108,6 @@ pre {
.webcrt-main { .webcrt-main {
min-height: 50vh; min-height: 50vh;
} }
.webcrt-source-tabs {
margin-bottom: 8px;
}
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {

File diff suppressed because it is too large Load diff

View file

@ -92,6 +92,46 @@ const parseApiResponse = async (res: Response): Promise<Record<string, unknown>>
} }
}; };
/** 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<string, unknown>;
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 <T,>(path: string): Promise<T> => { export const apiGet = async <T,>(path: string): Promise<T> => {
const res = await fetch(path, { headers: authHeaders() }); const res = await fetch(path, { headers: authHeaders() });
if (res.status === 401) { if (res.status === 401) {
@ -111,9 +151,9 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
const data = await parseApiResponse(res); const data = await parseApiResponse(res);
if (res.status === 401) { if (res.status === 401) {
handleUnauthorized(path); 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; return data as T;
}; };
@ -126,9 +166,9 @@ export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
const data = await parseApiResponse(res); const data = await parseApiResponse(res);
if (res.status === 401) { if (res.status === 401) {
handleUnauthorized(path); 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; return data as T;
}; };
@ -137,9 +177,9 @@ export const apiDelete = async <T,>(path: string): Promise<T> => {
const data = await parseApiResponse(res); const data = await parseApiResponse(res);
if (res.status === 401) { if (res.status === 401) {
handleUnauthorized(path); 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; return data as T;
}; };
@ -152,9 +192,9 @@ export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
const data = await parseApiResponse(res); const data = await parseApiResponse(res);
if (res.status === 401) { if (res.status === 401) {
handleUnauthorized(path); 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; return data as T;
}; };
@ -471,10 +511,34 @@ export const createWebcrtSession = (body: {
cols?: number; cols?: number;
rows?: number; rows?: number;
encoding?: string; encoding?: string;
keepalive_sec?: number;
post_login_commands?: string[]; post_login_commands?: string[];
async_connect?: boolean; async_connect?: boolean;
username?: string;
password?: string;
}) => apiPost<WebcrtSessionCreateResult>("/v1/webcrt/sessions", body); }) => apiPost<WebcrtSessionCreateResult>("/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<WebcrtQuickConnectResult>("/v1/webcrt/sessions/quick-connect", body);
export const closeWebcrtSession = (sessionId: string) => export const closeWebcrtSession = (sessionId: string) =>
apiDelete<{ ok: boolean; session_id: string; closed: boolean }>( apiDelete<{ ok: boolean; session_id: string; closed: boolean }>(
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`, `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
@ -565,7 +629,7 @@ export const fetchCliProfiles = () =>
apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles"); apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles");
export const fetchCliTargets = (params: { export const fetchCliTargets = (params: {
source?: "managed" | "ume" | "all"; source?: "managed" | "ume" | "webcrt" | "all";
keyword?: string; keyword?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;

View file

@ -257,6 +257,9 @@ export type CliTargetItem = {
ne_type?: string; ne_type?: string;
vendor?: string; vendor?: string;
device_type?: string; device_type?: string;
protocol?: string;
username?: string;
has_password?: boolean;
connect_status: string; connect_status: string;
cli_profile_ready?: boolean; cli_profile_ready?: boolean;
}; };

View file

@ -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<KeywordHighlightConfig>;
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;
}