mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
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:
parent
accd28c9a1
commit
f6b399e03e
23 changed files with 3682 additions and 225 deletions
|
|
@ -118,5 +118,8 @@ class CliTargetOut(BaseModel):
|
|||
ne_type: str = ""
|
||||
vendor: str = ""
|
||||
device_type: str = ""
|
||||
protocol: str = ""
|
||||
username: str = ""
|
||||
has_password: bool = False
|
||||
connect_status: str = "unknown"
|
||||
cli_profile_ready: bool = False
|
||||
|
|
|
|||
|
|
@ -258,8 +258,10 @@ def list_cli_targets(
|
|||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
from .device_types import WEBCRT_NE_SOURCE
|
||||
|
||||
src = str(source or "all").strip().lower()
|
||||
if src not in ("managed", "ume", "all"):
|
||||
if src not in ("managed", "ume", "webcrt", "all"):
|
||||
raise HTTPException(status_code=400, detail="invalid_source")
|
||||
ready = cli_profile_ready(db)
|
||||
page = max(1, int(page or 1))
|
||||
|
|
@ -267,15 +269,18 @@ def list_cli_targets(
|
|||
offset = (page - 1) * page_size
|
||||
kw = str(keyword or "").strip()
|
||||
|
||||
def _managed_item(row: Any) -> dict[str, Any]:
|
||||
def _managed_item(row: Any, *, list_source: str = "managed") -> dict[str, Any]:
|
||||
return CliTargetOut(
|
||||
source="managed",
|
||||
source=list_source,
|
||||
id=str(row.id),
|
||||
ume_ne_id=None,
|
||||
name=str(row.name or row.ip_address),
|
||||
ip_address=str(row.ip_address),
|
||||
vendor=str(row.vendor),
|
||||
device_type=str(row.device_type),
|
||||
protocol=str(getattr(row, "protocol", "") or ""),
|
||||
username=str(getattr(row, "username", "") or ""),
|
||||
has_password=bool(str(getattr(row, "password_enc", "") or "").strip()),
|
||||
connect_status=str(row.connect_status),
|
||||
cli_profile_ready=True,
|
||||
).model_dump()
|
||||
|
|
@ -293,19 +298,27 @@ def list_cli_targets(
|
|||
cli_profile_ready=ready,
|
||||
).model_dump()
|
||||
|
||||
def _managed_query():
|
||||
stmt = db.query(ManagedNE)
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
stmt = stmt.filter(
|
||||
ManagedNE.name.ilike(like)
|
||||
| ManagedNE.ip_address.ilike(like)
|
||||
| ManagedNE.username.ilike(like)
|
||||
| ManagedNE.tags.ilike(like)
|
||||
| ManagedNE.vendor.ilike(like)
|
||||
| ManagedNE.device_type.ilike(like)
|
||||
)
|
||||
return stmt.order_by(ManagedNE.updated_at.desc())
|
||||
def _apply_kw(stmt: Any) -> Any:
|
||||
if not kw:
|
||||
return stmt
|
||||
like = f"%{kw}%"
|
||||
return stmt.filter(
|
||||
ManagedNE.name.ilike(like)
|
||||
| ManagedNE.ip_address.ilike(like)
|
||||
| ManagedNE.username.ilike(like)
|
||||
| ManagedNE.tags.ilike(like)
|
||||
| ManagedNE.vendor.ilike(like)
|
||||
| ManagedNE.device_type.ilike(like)
|
||||
)
|
||||
|
||||
def _inventory_managed_query():
|
||||
# Asset inventory for WebCRT: exclude Quick-Connect (source=webcrt) rows.
|
||||
stmt = db.query(ManagedNE).filter(ManagedNE.source != WEBCRT_NE_SOURCE)
|
||||
return _apply_kw(stmt).order_by(ManagedNE.updated_at.desc())
|
||||
|
||||
def _webcrt_query():
|
||||
stmt = db.query(ManagedNE).filter(ManagedNE.source == WEBCRT_NE_SOURCE)
|
||||
return _apply_kw(stmt).order_by(ManagedNE.updated_at.desc())
|
||||
|
||||
def _ume_query():
|
||||
stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin(
|
||||
|
|
@ -323,10 +336,17 @@ def list_cli_targets(
|
|||
return stmt.order_by(UmeInventoryNE.ne_id.asc())
|
||||
|
||||
if src == "managed":
|
||||
mq = _managed_query()
|
||||
mq = _inventory_managed_query()
|
||||
total = int(mq.count())
|
||||
rows = mq.offset(offset).limit(page_size).all()
|
||||
items = [_managed_item(x) for x in rows]
|
||||
items = [_managed_item(x, list_source="managed") for x in rows]
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
if src == "webcrt":
|
||||
wq = _webcrt_query()
|
||||
total = int(wq.count())
|
||||
rows = wq.offset(offset).limit(page_size).all()
|
||||
items = [_managed_item(x, list_source="webcrt") for x in rows]
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
if src == "ume":
|
||||
|
|
@ -336,24 +356,40 @@ def list_cli_targets(
|
|||
items = [_ume_item(inv, ov) for inv, ov in rows]
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
# source=all: managed first, then UME, with correct cross-list pagination
|
||||
mq = _managed_query()
|
||||
# source=all: inventory managed → webcrt sessions → UME
|
||||
mq = _inventory_managed_query()
|
||||
wq = _webcrt_query()
|
||||
uq = _ume_query()
|
||||
m_total = int(mq.count())
|
||||
w_total = int(wq.count())
|
||||
u_total = int(uq.count())
|
||||
total = m_total + u_total
|
||||
total = m_total + w_total + u_total
|
||||
items: list[dict[str, Any]] = []
|
||||
if offset < m_total:
|
||||
take = min(page_size, m_total - offset)
|
||||
for row in mq.offset(offset).limit(take).all():
|
||||
items.append(_managed_item(row))
|
||||
need = page_size - len(items)
|
||||
if need > 0 and u_total > 0:
|
||||
for inv, ov in uq.offset(0).limit(need).all():
|
||||
items.append(_ume_item(inv, ov))
|
||||
|
||||
remaining = page_size
|
||||
cursor = offset
|
||||
|
||||
if cursor < m_total and remaining > 0:
|
||||
take = min(remaining, m_total - cursor)
|
||||
for row in mq.offset(cursor).limit(take).all():
|
||||
items.append(_managed_item(row, list_source="managed"))
|
||||
remaining -= take
|
||||
cursor = 0
|
||||
else:
|
||||
u_off = offset - m_total
|
||||
for inv, ov in uq.offset(u_off).limit(page_size).all():
|
||||
cursor = max(0, cursor - m_total)
|
||||
|
||||
if remaining > 0:
|
||||
if cursor < w_total:
|
||||
take = min(remaining, w_total - cursor)
|
||||
for row in wq.offset(cursor).limit(take).all():
|
||||
items.append(_managed_item(row, list_source="webcrt"))
|
||||
remaining -= take
|
||||
cursor = 0
|
||||
else:
|
||||
cursor = max(0, cursor - w_total)
|
||||
|
||||
if remaining > 0:
|
||||
for inv, ov in uq.offset(cursor).limit(remaining).all():
|
||||
items.append(_ume_item(inv, ov))
|
||||
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
|
|
|||
|
|
@ -88,8 +88,8 @@ class Settings(BaseSettings):
|
|||
webcrt_connect_timeout_sec: int = 90
|
||||
webcrt_attach_timeout_sec: int = 60
|
||||
webcrt_data_dir: str = "data/webcrt"
|
||||
# SSH transport keepalive interval (seconds); 0 disables.
|
||||
webcrt_keepalive_sec: int = 30
|
||||
# SSH transport keepalive interval (seconds); 0 disables (default off).
|
||||
webcrt_keepalive_sec: int = 0
|
||||
# Device anti-idle CLI nudge (0 = off). Keep off: NEs close idle VTY themselves.
|
||||
webcrt_anti_idle_sec: int = 0
|
||||
webcrt_anti_idle_payload: str = " "
|
||||
|
|
|
|||
|
|
@ -43,3 +43,9 @@ SUPPORTED_VENDORS: tuple[str, ...] = (
|
|||
"Nokia",
|
||||
"Other",
|
||||
)
|
||||
|
||||
# WebCRT "New Session": inventory types + raw interactive hosts (generic/linux).
|
||||
WEBCRT_DEVICE_TYPES: tuple[str, ...] = SUPPORTED_DEVICE_TYPES + ("linux", "generic")
|
||||
|
||||
# ManagedNE.source value for sessions created via WebCRT Quick Connect.
|
||||
WEBCRT_NE_SOURCE = "webcrt"
|
||||
|
|
|
|||
|
|
@ -975,6 +975,23 @@ def on_startup() -> None:
|
|||
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source VARCHAR(64) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source_ref VARCHAR(128) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT DEFAULT ''")
|
||||
# WebCRT sessions may share a host IP; uniqueness is enforced in ne_service for inventory only.
|
||||
for stmt in (
|
||||
"ALTER TABLE managed_ne DROP CONSTRAINT IF EXISTS managed_ne_ip_address_key",
|
||||
"DROP INDEX IF EXISTS managed_ne_ip_address_key",
|
||||
"DROP INDEX IF EXISTS ix_managed_ne_ip_address",
|
||||
"DROP INDEX IF EXISTS sqlite_autoindex_managed_ne_1",
|
||||
):
|
||||
try:
|
||||
conn.exec_driver_sql(stmt)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_managed_ne_ip_address ON managed_ne (ip_address)"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -278,7 +278,9 @@ class ManagedNE(Base):
|
|||
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||
vendor: Mapped[str] = mapped_column(String(64), default="Other", index=True)
|
||||
device_type: Mapped[str] = mapped_column(String(128), default="")
|
||||
ip_address: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
# Not unique: WebCRT sessions may share a host IP with distinct session names.
|
||||
# Inventory create/update still enforces uniqueness in ne_service.
|
||||
ip_address: Mapped[str] = mapped_column(String(128), index=True)
|
||||
port: Mapped[int] = mapped_column(Integer, default=22)
|
||||
protocol: Mapped[str] = mapped_column(String(16), default="ssh")
|
||||
username: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
|
|
|||
|
|
@ -54,6 +54,18 @@ def format_cli_failure(exc: BaseException | str, transcript: str = "", *, limit:
|
|||
"""Human/ops-facing failure message; promote auth rejects above Pattern/ReadTimeout."""
|
||||
if isinstance(exc, BaseException):
|
||||
exc_text = f"{type(exc).__name__}: {exc}"
|
||||
# Paramiko/Netmiko auth exceptions may carry little/no message text.
|
||||
try:
|
||||
import paramiko
|
||||
|
||||
if isinstance(exc, paramiko.AuthenticationException):
|
||||
detail = str(exc).strip() or type(exc).__name__
|
||||
return f"auth_rejected: {detail}"[:limit]
|
||||
except Exception:
|
||||
pass
|
||||
if "AuthenticationException" in type(exc).__name__:
|
||||
detail = str(exc).strip() or type(exc).__name__
|
||||
return f"auth_rejected: {detail}"[:limit]
|
||||
else:
|
||||
exc_text = str(exc or "")
|
||||
combined = f"{exc_text}\n{transcript or ''}"
|
||||
|
|
|
|||
|
|
@ -8,13 +8,22 @@ from typing import Any
|
|||
def normalize_netmiko_device_type(device_type: str, protocol: str) -> str:
|
||||
dt = str(device_type or "").strip()
|
||||
proto = str(protocol or "ssh").strip().lower()
|
||||
if "zte" in dt.lower():
|
||||
low = dt.lower()
|
||||
# Raw / SecureCRT-style sessions (WebCRT quick-connect stores device_type=generic).
|
||||
if low in ("generic", "generic_ssh", "generic_telnet", "terminal_server", "generic_termserver"):
|
||||
return "generic_telnet" if proto == "telnet" or "telnet" in low else "generic_termserver_ssh"
|
||||
# Netmiko ships linux / linux_ssh but not linux_telnet — use generic_telnet.
|
||||
if low in ("linux", "linux_ssh", "linux_telnet") or low.startswith("linux_"):
|
||||
if proto == "telnet" or "telnet" in low:
|
||||
return "generic_telnet"
|
||||
return "linux_ssh"
|
||||
if "zte" in low:
|
||||
if dt == "zte":
|
||||
return f"zte_zxros_{proto}"
|
||||
if "telnet" not in dt and "ssh" not in dt:
|
||||
if "telnet" not in low and "ssh" not in low:
|
||||
return f"{dt}_{proto}"
|
||||
return dt
|
||||
if "telnet" not in dt and "ssh" not in dt:
|
||||
if "telnet" not in low and "ssh" not in low:
|
||||
return f"{dt}_{proto}"
|
||||
return dt
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@ from fastapi import HTTPException
|
|||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS
|
||||
from .device_types import (
|
||||
SUPPORTED_DEVICE_TYPES,
|
||||
SUPPORTED_VENDORS,
|
||||
WEBCRT_DEVICE_TYPES,
|
||||
WEBCRT_NE_SOURCE,
|
||||
)
|
||||
from .models import ManagedNE, UmeInventoryNE
|
||||
from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_secret, encrypt_secret
|
||||
from .ne_schemas import (
|
||||
|
|
@ -41,6 +46,10 @@ IMPORT_COLUMNS = (
|
|||
|
||||
UME_SYNC_SOURCE = "ume_sync"
|
||||
UME_SYNC_TAG = "UME"
|
||||
# Re-export for callers (WebCRT quick-connect).
|
||||
WEBCRT_SOURCE = WEBCRT_NE_SOURCE
|
||||
# Re-export for callers (WebCRT Quick Connect).
|
||||
WEBCRT_SOURCE = WEBCRT_NE_SOURCE
|
||||
_BUILTIN_NE_TYPE_RULES: list[tuple[re.Pattern[str], str, str]] = [
|
||||
(re.compile(r"ZXR|ZXCTN|M6000|\bBN\b", re.I), "zte_zxros", "ZTE"),
|
||||
(re.compile(r"NE40|CE\b|ATN|MA5800|OptiX", re.I), "huawei", "Huawei"),
|
||||
|
|
@ -313,6 +322,190 @@ def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut:
|
|||
return row_to_out(row)
|
||||
|
||||
|
||||
def _normalize_webcrt_device_type(device_type: str) -> str:
|
||||
dt = str(device_type or "").strip()
|
||||
low = dt.lower()
|
||||
if low in ("linux", "linux_ssh", "linux_telnet"):
|
||||
return "linux"
|
||||
if low in ("generic", "generic_ssh", "generic_telnet", "terminal_server", "generic_termserver"):
|
||||
return "generic"
|
||||
return dt
|
||||
|
||||
|
||||
def upsert_webcrt_managed_ne(db: Session, body: ManagedNeCreate) -> tuple[ManagedNeOut, str]:
|
||||
"""Create/update a WebCRT-origin NE, or reuse an existing inventory NE by IP.
|
||||
|
||||
Returns ``(ne_out, action)`` where action is ``created`` | ``updated`` | ``reused``.
|
||||
"""
|
||||
_require_crypto()
|
||||
_validate_hop_on_create(body)
|
||||
ip = _normalize_ip(body.ip_address)
|
||||
if not ip:
|
||||
raise HTTPException(status_code=400, detail="ip_address_required")
|
||||
if not str(body.username or "").strip():
|
||||
raise HTTPException(status_code=400, detail="cli_username_required")
|
||||
device_type = _normalize_webcrt_device_type(body.device_type)
|
||||
if device_type not in WEBCRT_DEVICE_TYPES:
|
||||
raise HTTPException(status_code=400, detail="unsupported_device_type")
|
||||
|
||||
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
|
||||
now = _now()
|
||||
|
||||
if existing is not None:
|
||||
src = str(existing.source or "").strip()
|
||||
if src != WEBCRT_NE_SOURCE:
|
||||
# Do not overwrite inventory / UME-synced assets; just open them.
|
||||
return row_to_out(existing), "reused"
|
||||
|
||||
existing.name = str(body.name or "").strip() or existing.name or ip
|
||||
existing.vendor = _normalize_vendor(body.vendor) if str(body.vendor or "").strip() else (
|
||||
"Other" if device_type == "linux" else existing.vendor
|
||||
)
|
||||
existing.device_type = device_type
|
||||
existing.port = int(body.port or existing.port or 22)
|
||||
existing.protocol = _normalize_protocol(body.protocol)
|
||||
existing.username = str(body.username or "").strip()
|
||||
if str(body.password or "").strip():
|
||||
existing.password_enc = encrypt_secret(body.password)
|
||||
elif not str(existing.password_enc or "").strip() and not (
|
||||
body.hop_enabled
|
||||
and _normalize_hop_vendor(body.hop_vendor) == "bastion"
|
||||
and _normalize_hop_target_auth_mode(body.hop_target_auth_mode) == "bastion_managed"
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="password_required")
|
||||
existing.source = WEBCRT_NE_SOURCE
|
||||
_apply_hop_create(existing, body)
|
||||
existing.updated_at = now
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return row_to_out(existing), "updated"
|
||||
|
||||
if not str(body.password or "").strip() and not (
|
||||
body.hop_enabled
|
||||
and _normalize_hop_vendor(body.hop_vendor) == "bastion"
|
||||
and _normalize_hop_target_auth_mode(body.hop_target_auth_mode) == "bastion_managed"
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="password_required")
|
||||
|
||||
vendor = _normalize_vendor(body.vendor)
|
||||
if device_type == "linux" and not str(body.vendor or "").strip():
|
||||
vendor = "Other"
|
||||
|
||||
row = ManagedNE(
|
||||
name=str(body.name or "").strip() or ip,
|
||||
vendor=vendor,
|
||||
device_type=device_type,
|
||||
ip_address=ip,
|
||||
port=int(body.port or 22),
|
||||
protocol=_normalize_protocol(body.protocol),
|
||||
username=str(body.username or "").strip(),
|
||||
password_enc=encrypt_secret(body.password) if str(body.password or "").strip() else "",
|
||||
enable_secret_enc="",
|
||||
connect_status="unknown",
|
||||
tags=str(body.tags or "").strip(),
|
||||
remark=str(body.remark or "").strip(),
|
||||
source=WEBCRT_NE_SOURCE,
|
||||
source_ref="",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
_apply_hop_create(row, body)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row_to_out(row), "created"
|
||||
|
||||
|
||||
def _next_webcrt_session_name(db: Session, base: str) -> str:
|
||||
"""Return base, or ``base (1)``, ``base (2)``, … among WebCRT session names."""
|
||||
root = str(base or "").strip() or "session"
|
||||
rows = (
|
||||
db.query(ManagedNE.name)
|
||||
.filter(ManagedNE.source == WEBCRT_NE_SOURCE)
|
||||
.all()
|
||||
)
|
||||
taken = {str(r[0] or "").strip() for r in rows if str(r[0] or "").strip()}
|
||||
if root not in taken:
|
||||
return root
|
||||
n = 1
|
||||
while f"{root} ({n})" in taken:
|
||||
n += 1
|
||||
return f"{root} ({n})"
|
||||
|
||||
|
||||
def upsert_webcrt_session_host(
|
||||
db: Session,
|
||||
*,
|
||||
name: str = "",
|
||||
ip_address: str,
|
||||
port: int = 22,
|
||||
protocol: str = "ssh",
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
save_password: bool = False,
|
||||
) -> tuple[ManagedNeOut, str]:
|
||||
"""Create a WebCRT session host (linux, no hop). Always inserts a new row.
|
||||
|
||||
Same IP is allowed; session name auto-suffixes ``(1)``, ``(2)``, … on collision.
|
||||
Telnet never persists a password. SSH persists password only when ``save_password``.
|
||||
Returns ``(ne_out, \"created\")``.
|
||||
"""
|
||||
_require_crypto()
|
||||
ip = _normalize_ip(ip_address)
|
||||
if not ip:
|
||||
raise HTTPException(status_code=400, detail="ip_address_required")
|
||||
proto = _normalize_protocol(protocol)
|
||||
user = str(username or "").strip()
|
||||
pwd = str(password or "")
|
||||
if proto == "ssh" and not user:
|
||||
raise HTTPException(status_code=400, detail="cli_username_required")
|
||||
if proto == "ssh" and save_password and not pwd.strip():
|
||||
raise HTTPException(status_code=400, detail="password_required")
|
||||
|
||||
now = _now()
|
||||
display_name = _next_webcrt_session_name(db, str(name or "").strip() or ip)
|
||||
|
||||
password_enc = ""
|
||||
if proto == "ssh" and save_password and pwd.strip():
|
||||
password_enc = encrypt_secret(pwd)
|
||||
|
||||
row = ManagedNE(
|
||||
name=display_name,
|
||||
vendor="Other",
|
||||
# generic → Netmiko terminal_server: SSH auth then raw PTY (no linux session prep).
|
||||
device_type="generic",
|
||||
ip_address=ip,
|
||||
port=int(port or (23 if proto == "telnet" else 22)),
|
||||
protocol=proto,
|
||||
username=user,
|
||||
password_enc=password_enc,
|
||||
enable_secret_enc="",
|
||||
connect_status="unknown",
|
||||
tags="",
|
||||
remark="",
|
||||
source=WEBCRT_NE_SOURCE,
|
||||
source_ref="",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
# Stale unique index on ip_address → restart API after migration, or drop constraint manually.
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
if isinstance(exc, IntegrityError):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="ip_address_conflict_restart_required",
|
||||
) from exc
|
||||
raise
|
||||
db.refresh(row)
|
||||
return row_to_out(row), "created"
|
||||
|
||||
|
||||
def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> ManagedNeOut:
|
||||
row = db.get(ManagedNE, ne_id)
|
||||
if not row:
|
||||
|
|
|
|||
|
|
@ -243,9 +243,10 @@ def _netmiko_driver_class(device_type: str) -> type:
|
|||
|
||||
|
||||
def _interactive_driver_class(base_cls: type) -> type:
|
||||
"""Subclass that skips Netmiko auto terminal-length / terminal-width commands.
|
||||
"""Subclass for WebCRT: raw interactive PTY after transport auth (SecureCRT-like).
|
||||
|
||||
Collection and MCP exec need paging disabled; WebCRT must not inject those.
|
||||
Skips Netmiko session prep (prompt discovery, terminal length/width, force RETURN)
|
||||
so the channel is left for the user — not consumed by library automation.
|
||||
"""
|
||||
|
||||
class _InteractiveSession(base_cls): # type: ignore[misc,valid-type]
|
||||
|
|
@ -255,6 +256,17 @@ def _interactive_driver_class(base_cls: type) -> type:
|
|||
def set_terminal_width(self, *args: Any, **kwargs: Any) -> str: # noqa: ANN401
|
||||
return ""
|
||||
|
||||
def session_preparation(self) -> None:
|
||||
return None
|
||||
|
||||
def _try_session_preparation(self, force_data: bool = True) -> None: # noqa: FBT001, FBT002
|
||||
del force_data
|
||||
try:
|
||||
self.session_preparation()
|
||||
except Exception:
|
||||
self.disconnect()
|
||||
raise
|
||||
|
||||
_InteractiveSession.__name__ = f"Interactive{getattr(base_cls, '__name__', 'Netmiko')}"
|
||||
return _InteractiveSession
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
|
||||
|
|
@ -37,9 +38,32 @@ class WebcrtSessionCreate(BaseModel):
|
|||
cols: int = Field(default=80, ge=20, le=500)
|
||||
rows: int = Field(default=24, ge=5, le=200)
|
||||
encoding: str = Field(default="utf-8")
|
||||
# SSH transport keepalive interval (seconds). None = server default; 0 = off.
|
||||
keepalive_sec: int | None = Field(default=None, ge=0, le=600)
|
||||
post_login_commands: list[str] = Field(default_factory=list)
|
||||
# Default async so UI can open WS while connect runs; tests may force sync via service API.
|
||||
async_connect: bool = Field(default=True)
|
||||
# One-shot credentials (not written to DB).
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class WebcrtQuickConnectBody(BaseModel):
|
||||
"""SecureCRT-style: upsert session host then open a session."""
|
||||
|
||||
name: str = ""
|
||||
ip_address: str
|
||||
port: int = 22
|
||||
protocol: str = "ssh"
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
save_password: bool = False
|
||||
cols: int = Field(default=80, ge=20, le=500)
|
||||
rows: int = Field(default=24, ge=5, le=200)
|
||||
encoding: str = Field(default="utf-8")
|
||||
keepalive_sec: int | None = Field(default=None, ge=0, le=600)
|
||||
post_login_commands: list[str] = Field(default_factory=list)
|
||||
async_connect: bool = Field(default=True)
|
||||
|
||||
|
||||
class WebcrtSftpListBody(BaseModel):
|
||||
|
|
@ -68,6 +92,13 @@ def api_list_sessions() -> dict[str, Any]:
|
|||
return list_sessions()
|
||||
|
||||
|
||||
@router.get("/meta/device-types")
|
||||
def api_webcrt_device_types() -> dict[str, Any]:
|
||||
from .device_types import SUPPORTED_VENDORS, WEBCRT_DEVICE_TYPES
|
||||
|
||||
return {"device_types": list(WEBCRT_DEVICE_TYPES), "vendors": list(SUPPORTED_VENDORS)}
|
||||
|
||||
|
||||
@router.post("/sessions")
|
||||
def api_create_session(
|
||||
body: WebcrtSessionCreate,
|
||||
|
|
@ -86,11 +117,82 @@ def api_create_session(
|
|||
rows=body.rows,
|
||||
client=_client_label(request=request),
|
||||
encoding=body.encoding,
|
||||
keepalive_sec=body.keepalive_sec,
|
||||
post_login_commands=list(body.post_login_commands or [])[:20],
|
||||
async_connect=bool(body.async_connect),
|
||||
username_override=body.username,
|
||||
password_override=body.password,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/quick-connect")
|
||||
def api_quick_connect(
|
||||
body: WebcrtQuickConnectBody,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
from .ne_service import upsert_webcrt_session_host
|
||||
|
||||
proto = str(body.protocol or "ssh").strip().lower()
|
||||
if proto not in ("ssh", "telnet"):
|
||||
raise HTTPException(status_code=400, detail="invalid_protocol")
|
||||
save_password = bool(body.save_password) and proto == "ssh"
|
||||
ne_out, action = upsert_webcrt_session_host(
|
||||
db,
|
||||
name=body.name,
|
||||
ip_address=body.ip_address,
|
||||
port=body.port,
|
||||
protocol=proto,
|
||||
username=body.username,
|
||||
password=body.password,
|
||||
save_password=save_password,
|
||||
)
|
||||
# Pass SSH credentials as one-shot overrides (covers unsaved password + reused inventory).
|
||||
pwd_override: str | None = None
|
||||
user_override: str | None = None
|
||||
if proto == "ssh":
|
||||
user_override = str(body.username or "").strip() or None
|
||||
if str(body.password or "").strip():
|
||||
pwd_override = str(body.password)
|
||||
# SSH with password: wait for auth so wrong credentials can re-prompt (SecureCRT-like).
|
||||
wait_for_auth = proto == "ssh" and bool(pwd_override)
|
||||
async_connect = bool(body.async_connect) and not wait_for_auth
|
||||
try:
|
||||
session = create_session(
|
||||
db,
|
||||
ne_id=ne_out.id,
|
||||
cols=body.cols,
|
||||
rows=body.rows,
|
||||
client=_client_label(request=request),
|
||||
encoding=body.encoding,
|
||||
keepalive_sec=body.keepalive_sec,
|
||||
post_login_commands=list(body.post_login_commands or [])[:20],
|
||||
async_connect=async_connect,
|
||||
username_override=user_override,
|
||||
password_override=pwd_override,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
# NE row already exists; return it so the UI retries in place (no duplicate hosts).
|
||||
if exc.status_code == 502 and proto == "ssh":
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "connect_failed",
|
||||
"message": str(exc.detail or "connect_failed"),
|
||||
"ne": ne_out.model_dump(mode="json"),
|
||||
"ne_action": action,
|
||||
"list_source": "webcrt",
|
||||
},
|
||||
) from exc
|
||||
raise
|
||||
return {
|
||||
**session,
|
||||
"ne": ne_out.model_dump(mode="json"),
|
||||
"ne_action": action,
|
||||
"list_source": "webcrt",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
def api_close_session(session_id: str, request: Request) -> dict[str, Any]:
|
||||
return close_session(session_id, reason="client_delete", client=_client_label(request=request))
|
||||
|
|
@ -181,6 +283,8 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
{
|
||||
"type": "status",
|
||||
"state": "connecting" if sess.state == "connecting" else "connected",
|
||||
"phase": "authenticating" if sess.state == "connecting" else "ready",
|
||||
"message": "authenticating" if sess.state == "connecting" else "",
|
||||
"session_id": sess.session_id,
|
||||
"ne_id": sess.ne_id,
|
||||
"ne_name": sess.ne_name,
|
||||
|
|
@ -195,24 +299,61 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
}
|
||||
)
|
||||
|
||||
# Wait for async connect without blocking the event loop.
|
||||
# Wait for async connect without blocking the event loop; emit phase updates.
|
||||
if sess.state == "connecting":
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: wait_session_ready(
|
||||
session_id,
|
||||
timeout=max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15,
|
||||
),
|
||||
)
|
||||
sess = get_session(session_id) or sess
|
||||
except HTTPException as exc:
|
||||
await websocket.send_json(
|
||||
{"type": "status", "state": "error", "message": str(exc.detail)}
|
||||
)
|
||||
await websocket.close(code=4502)
|
||||
return
|
||||
budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15
|
||||
deadline = time.time() + budget
|
||||
while True:
|
||||
cur = get_session(session_id) or sess
|
||||
if cur.state != "connecting":
|
||||
sess = cur
|
||||
break
|
||||
elapsed = max(0.0, time.time() - float(cur.connect_started_at or time.time()))
|
||||
phase = "authenticating" if elapsed < 6.0 else "waiting_prompt"
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "status",
|
||||
"state": "connecting",
|
||||
"phase": phase,
|
||||
"message": phase,
|
||||
"elapsed_ms": int(elapsed * 1000),
|
||||
"session_id": cur.session_id,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
await websocket.send_json(
|
||||
{"type": "status", "state": "error", "message": "connect_timeout"}
|
||||
)
|
||||
await websocket.close(code=4502)
|
||||
return
|
||||
slice_timeout = min(1.0, max(0.2, remaining))
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda t=slice_timeout: wait_session_ready(session_id, timeout=t),
|
||||
)
|
||||
sess = get_session(session_id) or cur
|
||||
break
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == 504:
|
||||
# Slice timeout while still connecting — keep polling with progress.
|
||||
continue
|
||||
await websocket.send_json(
|
||||
{"type": "status", "state": "error", "message": str(exc.detail)}
|
||||
)
|
||||
await websocket.close(code=4502)
|
||||
return
|
||||
except Exception as exc:
|
||||
await websocket.send_json(
|
||||
{"type": "status", "state": "error", "message": f"connect_failed:{exc}"}
|
||||
)
|
||||
await websocket.close(code=4502)
|
||||
return
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ def uses_network_cli_keymap(device_type: str = "", vendor: str = "") -> bool:
|
|||
blob = f"{device_type} {vendor}".strip().lower()
|
||||
if not blob:
|
||||
return True
|
||||
for token in ("linux", "ubuntu", "centos", "debian", "redhat", "unix"):
|
||||
for token in ("linux", "ubuntu", "centos", "debian", "redhat", "unix", "generic_telnet", "generic"):
|
||||
if token in blob:
|
||||
return False
|
||||
return True
|
||||
|
|
@ -158,10 +158,35 @@ def _looks_like_cli_prompt(text: str) -> bool:
|
|||
s = str(text or "").rstrip()
|
||||
if not s:
|
||||
return False
|
||||
# Buffer races can leave a stray ':' after Huawei ``<r1>`` (from prior ``[Y/N]:``).
|
||||
if s.endswith(":") and ">" in s:
|
||||
s = s[:-1].rstrip()
|
||||
# Common network CLI prompts: <r1> [HUAWEI] Router# Router>
|
||||
return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s))
|
||||
|
||||
|
||||
def _looks_like_login_prompt(text: str) -> bool:
|
||||
"""True when the transcript ends at Username:/Login:/Password: (interactive auth)."""
|
||||
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [ln.strip() for ln in s.split("\n") if ln.strip()]
|
||||
if not lines:
|
||||
return False
|
||||
last = lines[-1]
|
||||
return bool(re.search(r"(?i)(user\s*name|login|password)\s*:\s*$", last))
|
||||
|
||||
|
||||
def _looks_like_password_change_prompt(text: str) -> bool:
|
||||
"""Huawei/VRP post-auth ``Change now? [Y/N]:`` (Netmiko already answers N)."""
|
||||
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [ln.strip() for ln in s.split("\n") if ln.strip()]
|
||||
if not lines:
|
||||
return False
|
||||
last = lines[-1]
|
||||
return bool(re.search(r"(?i)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:\s*$", last)) or bool(
|
||||
re.search(r"\[Y/N\]\s*:\s*$", last, flags=re.I)
|
||||
)
|
||||
|
||||
|
||||
# Cisco/Netmiko often yields "R2#R2#" when a sync Enter is appended without a newline.
|
||||
_GLUED_PROMPT_RE = re.compile(r"(?<=[#>])(?=(?:[A-Za-z0-9][\w.\-:]{0,62})[#>])")
|
||||
|
||||
|
|
@ -188,47 +213,74 @@ def prepare_bootstrap_output(text: str) -> str:
|
|||
Trailing newline would leave the cursor on a blank line so the first typed line
|
||||
looks wrong; cursor should sit after the prompt like a real CRT.
|
||||
"""
|
||||
return normalize_cli_transcript(text)
|
||||
s = normalize_cli_transcript(text)
|
||||
# Drop a stray ':' glued onto Huawei ``<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:
|
||||
"""Discard leftover bytes on the live channel (SSH/Telnet) after login priming."""
|
||||
def _capture_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> str:
|
||||
"""Read leftover PTY bytes into text (banner/MOTD after SSH auth).
|
||||
|
||||
Interactive WebCRT skips Netmiko session_preparation, so the post-auth banner
|
||||
often never lands in ``session_log`` and must be pulled from the live channel.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
channel = getattr(conn, "remote_conn", None)
|
||||
if channel is None:
|
||||
try:
|
||||
_drain_channel(conn, rounds=max(2, int(duration / 0.05)), wait=0.05)
|
||||
return _drain_channel(conn, rounds=max(2, int(duration / 0.05)), wait=0.05)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
return ""
|
||||
end = time.time() + max(0.1, float(duration))
|
||||
while time.time() < end:
|
||||
got = False
|
||||
try:
|
||||
if hasattr(channel, "recv_ready") and hasattr(channel, "recv") and channel.recv_ready():
|
||||
channel.recv(65535)
|
||||
got = True
|
||||
elif hasattr(channel, "read_very_eager"):
|
||||
# Paramiko SSH channel
|
||||
if hasattr(channel, "recv_ready") and hasattr(channel, "recv"):
|
||||
if channel.recv_ready():
|
||||
raw = channel.recv(65535)
|
||||
if raw:
|
||||
got = True
|
||||
if isinstance(raw, bytes):
|
||||
chunks.append(raw.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
chunks.append(str(raw))
|
||||
# telnetlib-style
|
||||
elif callable(getattr(channel, "read_very_eager", None)):
|
||||
data = channel.read_very_eager()
|
||||
if data:
|
||||
got = True
|
||||
if isinstance(data, bytes):
|
||||
chunks.append(data.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
chunks.append(str(data))
|
||||
else:
|
||||
part = conn.read_channel()
|
||||
if part:
|
||||
got = True
|
||||
chunks.append(str(part))
|
||||
except Exception:
|
||||
break
|
||||
if not got:
|
||||
time.sleep(0.04)
|
||||
return "".join(chunks)
|
||||
|
||||
|
||||
def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool = False) -> None:
|
||||
"""Send one RETURN after Netmiko login so the interactive channel is fully ready.
|
||||
def _drain_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> None:
|
||||
"""Discard leftover bytes on the live channel (SSH/Telnet) after login priming."""
|
||||
_capture_raw_channel(conn, duration=duration)
|
||||
|
||||
|
||||
def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool = False) -> str:
|
||||
"""Sync interactive channel after login; return captured banner/prompt text.
|
||||
|
||||
Skip the sync Enter when the login transcript already ends with a CLI prompt —
|
||||
otherwise slow Cisco VMs accumulate duplicate ``R2#`` lines in the bootstrap.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
try:
|
||||
_drain_raw_channel(conn, duration=0.15)
|
||||
parts.append(_capture_raw_channel(conn, duration=0.25))
|
||||
except Exception:
|
||||
pass
|
||||
if not already_prompted:
|
||||
|
|
@ -238,15 +290,16 @@ def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool =
|
|||
try:
|
||||
conn.write_channel("\n")
|
||||
except Exception:
|
||||
return
|
||||
return "".join(parts)
|
||||
try:
|
||||
_drain_channel(conn, rounds=6, wait=0.08)
|
||||
parts.append(_drain_channel(conn, rounds=6, wait=0.08))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_drain_raw_channel(conn, duration=0.35)
|
||||
parts.append(_capture_raw_channel(conn, duration=0.35))
|
||||
except Exception:
|
||||
pass
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _is_prompt_only_echo(text: str, prompt_hint: str = "") -> bool:
|
||||
|
|
@ -361,6 +414,7 @@ class WebcrtSession:
|
|||
vendor: str = ""
|
||||
cli_keymap: bool = True
|
||||
encoding: str = "utf-8"
|
||||
keepalive_sec: int = 0
|
||||
conn: ConnectHandler | None = None
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
|
|
@ -802,19 +856,25 @@ def _webcrt_creds_ready(creds: dict[str, Any]) -> bool:
|
|||
|
||||
Bastion-managed hops store the target password on the bastion side, so an empty
|
||||
NE password is valid (same as connectivity test). Direct / manual / Linux hops
|
||||
still require a target password.
|
||||
still require a target password for SSH.
|
||||
|
||||
Telnet (no hop) allows empty username/password so the user can authenticate
|
||||
interactively in the terminal (SecureCRT-style).
|
||||
"""
|
||||
if not str(creds.get("username") or "").strip():
|
||||
return False
|
||||
hop_enabled = bool(creds.get("hop_enabled"))
|
||||
hop_vendor = str(creds.get("hop_vendor") or "").strip().lower()
|
||||
auth_mode = str(creds.get("hop_target_auth_mode") or "bastion_managed").strip().lower()
|
||||
protocol = str(creds.get("protocol") or "ssh").strip().lower()
|
||||
if hop_enabled and hop_vendor == "bastion" and auth_mode == "bastion_managed":
|
||||
return bool(
|
||||
str(creds.get("hop_host") or "").strip()
|
||||
and str(creds.get("hop_username") or "").strip()
|
||||
and str(creds.get("hop_password") or "")
|
||||
)
|
||||
if protocol == "telnet" and not hop_enabled:
|
||||
return True
|
||||
if not str(creds.get("username") or "").strip():
|
||||
return False
|
||||
return bool(str(creds.get("password") or ""))
|
||||
|
||||
|
||||
|
|
@ -835,6 +895,7 @@ def _finish_connect(
|
|||
cols=sess.cols,
|
||||
rows=sess.rows,
|
||||
interactive=True,
|
||||
keepalive=int(sess.keepalive_sec or 0),
|
||||
)
|
||||
except Exception as exc:
|
||||
partial = _session_log_text(log_buf).strip()
|
||||
|
|
@ -868,29 +929,57 @@ def _finish_connect(
|
|||
pass
|
||||
|
||||
pre_log = _session_log_text(log_buf)
|
||||
already_prompted = _looks_like_cli_prompt(pre_log)
|
||||
_prime_interactive_channel(conn, already_prompted=already_prompted)
|
||||
bootstrap = prepare_bootstrap_output(_session_log_text(log_buf))
|
||||
if not bootstrap.strip():
|
||||
try:
|
||||
more = _drain_channel(conn, rounds=6, wait=0.08)
|
||||
except Exception:
|
||||
more = ""
|
||||
if more:
|
||||
bootstrap = prepare_bootstrap_output(more)
|
||||
# Slow Cisco VMs leave extra prompt bytes; discard before the live reader starts.
|
||||
# Pull post-auth banner/MOTD from the PTY. With interactive no-op session_preparation
|
||||
# (generic_termserver), Netmiko session_log is often empty — do not discard these bytes.
|
||||
try:
|
||||
_drain_raw_channel(conn, duration=0.55)
|
||||
early = _capture_raw_channel(conn, duration=0.35)
|
||||
except Exception:
|
||||
early = ""
|
||||
seed = f"{pre_log}{early}"
|
||||
already_prompted = _looks_like_cli_prompt(seed)
|
||||
primed = ""
|
||||
# Do not send Enter at Username:/Password: or Huawei password-change [Y/N]:
|
||||
# (Netmiko telnet_login already answers password-change with "N").
|
||||
if _looks_like_login_prompt(seed) or _looks_like_password_change_prompt(seed):
|
||||
try:
|
||||
primed = _capture_raw_channel(conn, duration=0.9)
|
||||
except Exception:
|
||||
primed = ""
|
||||
else:
|
||||
try:
|
||||
primed = _prime_interactive_channel(conn, already_prompted=already_prompted)
|
||||
except Exception:
|
||||
primed = ""
|
||||
combined = f"{seed}{primed}"
|
||||
# Final settle: keep stragglers in bootstrap (normalize collapses duplicate prompts).
|
||||
try:
|
||||
combined += _capture_raw_channel(conn, duration=0.35)
|
||||
except Exception:
|
||||
pass
|
||||
if not str(combined).strip():
|
||||
try:
|
||||
combined = _drain_channel(conn, rounds=6, wait=0.08)
|
||||
except Exception:
|
||||
combined = ""
|
||||
bootstrap = prepare_bootstrap_output(combined)
|
||||
# Discard lone punctuation left on the wire (would glue onto ``<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)
|
||||
sess.conn = conn
|
||||
sess.cli_hop_guard = bool(hop_guard)
|
||||
sess.cli_hop_prompt = str((hop_guard or {}).get("hop_prompt") or "")
|
||||
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
|
||||
# Bootstrap already ends at a prompt → never nudge another Enter on WS attach.
|
||||
sess.needs_live_prompt = not _looks_like_cli_prompt(bootstrap)
|
||||
# Nudge Enter on WS attach only when we still need a shell prompt.
|
||||
# Never when already at CLI prompt or Username:/Password: (would empty-submit login).
|
||||
sess.needs_live_prompt = (
|
||||
not _looks_like_cli_prompt(bootstrap) and not _looks_like_login_prompt(bootstrap)
|
||||
)
|
||||
sess.open_session_log()
|
||||
if bootstrap:
|
||||
sess.append_session_log(bootstrap if bootstrap.endswith("\n") else bootstrap + "\n")
|
||||
|
|
@ -954,8 +1043,11 @@ def create_session(
|
|||
rows: int = 24,
|
||||
client: str = "",
|
||||
encoding: str = "utf-8",
|
||||
keepalive_sec: int | None = None,
|
||||
post_login_commands: list[str] | None = None,
|
||||
async_connect: bool = True,
|
||||
username_override: str | None = None,
|
||||
password_override: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from .cli_resolve import resolve_cli_target
|
||||
|
||||
|
|
@ -975,6 +1067,19 @@ def create_session(
|
|||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"credential_error:{exc}") from exc
|
||||
|
||||
# One-shot credentials for SecureCRT-style "do not save password" / retry.
|
||||
if username_override is not None and str(username_override).strip():
|
||||
creds["username"] = str(username_override).strip()
|
||||
if password_override is not None:
|
||||
creds["password"] = str(password_override)
|
||||
|
||||
protocol = str(device.get("protocol") or creds.get("protocol") or "ssh").strip().lower()
|
||||
creds["protocol"] = protocol
|
||||
# Netmiko telnet drivers dislike a completely missing username; use a placeholder
|
||||
# for the wire only (interactive login still happens in the terminal).
|
||||
if protocol == "telnet" and not bool(creds.get("hop_enabled")) and not str(creds.get("username") or "").strip():
|
||||
creds["username"] = "telnet"
|
||||
|
||||
if not _webcrt_creds_ready(creds):
|
||||
raise HTTPException(status_code=400, detail="credentials_incomplete")
|
||||
|
||||
|
|
@ -985,11 +1090,14 @@ def create_session(
|
|||
target_id = str(device.get("id") or mid or uid)
|
||||
target_ip = str(device.get("ip_address") or "")
|
||||
target_name = str(device.get("name") or target_ip)
|
||||
protocol = str(device.get("protocol") or creds.get("protocol") or "ssh")
|
||||
device_type = str(device.get("device_type") or creds.get("device_type") or "")
|
||||
vendor = str(device.get("vendor") or creds.get("vendor") or "")
|
||||
cli_keymap = uses_network_cli_keymap(device_type, vendor)
|
||||
enc = _normalize_encoding(encoding)
|
||||
if keepalive_sec is None:
|
||||
ka = max(0, int(getattr(settings, "webcrt_keepalive_sec", 0) or 0))
|
||||
else:
|
||||
ka = max(0, min(600, int(keepalive_sec)))
|
||||
|
||||
sess = WebcrtSession(
|
||||
session_id=session_id,
|
||||
|
|
@ -1003,6 +1111,7 @@ def create_session(
|
|||
vendor=vendor,
|
||||
cli_keymap=cli_keymap,
|
||||
encoding=enc,
|
||||
keepalive_sec=ka,
|
||||
state="connecting",
|
||||
post_login_commands=list(post_login_commands or [])[:20],
|
||||
)
|
||||
|
|
@ -1057,6 +1166,7 @@ def create_session(
|
|||
"cols": sess.cols,
|
||||
"rows": sess.rows,
|
||||
"encoding": enc,
|
||||
"keepalive_sec": ka,
|
||||
"state": sess.state,
|
||||
"ws_path": f"/v1/webcrt/sessions/{session_id}/ws",
|
||||
"cli_hop": bool(sess.cli_hop_guard),
|
||||
|
|
@ -1153,6 +1263,7 @@ def list_sessions() -> dict[str, Any]:
|
|||
"ne_ip": s.ne_ip,
|
||||
"protocol": s.protocol,
|
||||
"encoding": s.encoding,
|
||||
"keepalive_sec": int(s.keepalive_sec or 0),
|
||||
"state": s.state,
|
||||
"attached": s.attached,
|
||||
"created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(),
|
||||
|
|
@ -1174,7 +1285,7 @@ def list_sessions() -> dict[str, Any]:
|
|||
"total": len(items),
|
||||
"max_sessions": max(1, int(settings.webcrt_max_sessions or 20)),
|
||||
"idle_timeout_sec": max(60, int(settings.webcrt_idle_timeout_sec or 1800)),
|
||||
"keepalive_sec": int(getattr(settings, "webcrt_keepalive_sec", 30) or 0),
|
||||
"keepalive_sec": int(getattr(settings, "webcrt_keepalive_sec", 0) or 0),
|
||||
"anti_idle_sec": int(getattr(settings, "webcrt_anti_idle_sec", 0) or 0),
|
||||
"items": items,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,18 @@ from sqlalchemy.pool import StaticPool
|
|||
from netx_api.config import settings
|
||||
from netx_api.db import Base, get_db
|
||||
from netx_api.main import app
|
||||
from netx_api.models import ManagedNE, UmeInventoryNE # noqa: F401 — register table on Base
|
||||
from netx_api.models import CliConnectProfile, ManagedNE, UmeInventoryNE # noqa: F401 — register table on Base
|
||||
from netx_api.ne_connect import hostname_probe_command, parse_hostname_from_output
|
||||
from netx_api.ne_crypto import decrypt_secret, encrypt_secret
|
||||
from netx_api.ne_service import UME_SYNC_SOURCE, create_managed_ne, import_managed_ne
|
||||
from netx_api.cli_service import list_cli_targets
|
||||
from netx_api.device_types import WEBCRT_DEVICE_TYPES, WEBCRT_NE_SOURCE
|
||||
from netx_api.ne_service import (
|
||||
UME_SYNC_SOURCE,
|
||||
create_managed_ne,
|
||||
import_managed_ne,
|
||||
upsert_webcrt_managed_ne,
|
||||
upsert_webcrt_session_host,
|
||||
)
|
||||
from netx_api.ne_schemas import ManagedNeCreate
|
||||
|
||||
|
||||
|
|
@ -319,5 +327,182 @@ class ManagedNeCreateOptionalPasswordTests(unittest.TestCase):
|
|||
self.assertEqual(row.password_enc, "")
|
||||
|
||||
|
||||
class WebcrtUpsertAndTargetsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._orig = settings.credential_secret_key
|
||||
settings.credential_secret_key = Fernet.generate_key().decode()
|
||||
self.engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
ManagedNE.__table__.create(bind=self.engine, checkfirst=True)
|
||||
UmeInventoryNE.__table__.create(bind=self.engine, checkfirst=True)
|
||||
CliConnectProfile.__table__.create(bind=self.engine, checkfirst=True)
|
||||
self.db = sessionmaker(bind=self.engine)()
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
settings.credential_secret_key = self._orig
|
||||
|
||||
def test_webcrt_device_types_include_linux(self):
|
||||
self.assertIn("linux", WEBCRT_DEVICE_TYPES)
|
||||
self.assertIn("generic", WEBCRT_DEVICE_TYPES)
|
||||
self.assertIn("zte_zxros", WEBCRT_DEVICE_TYPES)
|
||||
|
||||
def test_upsert_webcrt_create_update_reuse(self):
|
||||
created, action = upsert_webcrt_managed_ne(
|
||||
self.db,
|
||||
ManagedNeCreate(
|
||||
name="linux-1",
|
||||
vendor="Other",
|
||||
device_type="linux",
|
||||
ip_address="10.8.8.8",
|
||||
username="root",
|
||||
password="secret",
|
||||
),
|
||||
)
|
||||
self.assertEqual(action, "created")
|
||||
row = self.db.query(ManagedNE).filter(ManagedNE.id == created.id).one()
|
||||
self.assertEqual(row.source, WEBCRT_NE_SOURCE)
|
||||
self.assertEqual(row.device_type, "linux")
|
||||
|
||||
updated, action2 = upsert_webcrt_managed_ne(
|
||||
self.db,
|
||||
ManagedNeCreate(
|
||||
name="linux-1b",
|
||||
vendor="Other",
|
||||
device_type="linux_ssh",
|
||||
ip_address="10.8.8.8",
|
||||
username="root",
|
||||
password="secret2",
|
||||
),
|
||||
)
|
||||
self.assertEqual(action2, "updated")
|
||||
self.assertEqual(updated.id, created.id)
|
||||
self.assertEqual(updated.name, "linux-1b")
|
||||
self.assertEqual(updated.device_type, "linux")
|
||||
|
||||
inv = create_managed_ne(
|
||||
self.db,
|
||||
ManagedNeCreate(
|
||||
vendor="ZTE",
|
||||
device_type="zte_zxros",
|
||||
ip_address="10.8.8.9",
|
||||
username="admin",
|
||||
password="p",
|
||||
),
|
||||
)
|
||||
reused, action3 = upsert_webcrt_managed_ne(
|
||||
self.db,
|
||||
ManagedNeCreate(
|
||||
vendor="ZTE",
|
||||
device_type="zte_zxros",
|
||||
ip_address="10.8.8.9",
|
||||
username="admin",
|
||||
password="ignored",
|
||||
),
|
||||
)
|
||||
self.assertEqual(action3, "reused")
|
||||
self.assertEqual(reused.id, inv.id)
|
||||
# Inventory row must not be rewritten as webcrt.
|
||||
keep = self.db.query(ManagedNE).filter(ManagedNE.id == inv.id).one()
|
||||
self.assertNotEqual(keep.source, WEBCRT_NE_SOURCE)
|
||||
|
||||
def test_list_cli_targets_webcrt_source(self):
|
||||
upsert_webcrt_managed_ne(
|
||||
self.db,
|
||||
ManagedNeCreate(
|
||||
name="sess-a",
|
||||
vendor="Other",
|
||||
device_type="linux",
|
||||
ip_address="10.7.7.7",
|
||||
username="u",
|
||||
password="p",
|
||||
),
|
||||
)
|
||||
create_managed_ne(
|
||||
self.db,
|
||||
ManagedNeCreate(
|
||||
vendor="ZTE",
|
||||
device_type="zte_zxros",
|
||||
ip_address="10.7.7.8",
|
||||
username="admin",
|
||||
password="p",
|
||||
),
|
||||
)
|
||||
webcrt = list_cli_targets(self.db, source="webcrt", page=1, page_size=50)
|
||||
self.assertEqual(webcrt["total"], 1)
|
||||
self.assertEqual(webcrt["items"][0]["source"], "webcrt")
|
||||
self.assertEqual(webcrt["items"][0]["ip_address"], "10.7.7.7")
|
||||
self.assertTrue(webcrt["items"][0]["has_password"])
|
||||
|
||||
managed = list_cli_targets(self.db, source="managed", page=1, page_size=50)
|
||||
ips = {x["ip_address"] for x in managed["items"]}
|
||||
self.assertIn("10.7.7.8", ips)
|
||||
self.assertNotIn("10.7.7.7", ips)
|
||||
|
||||
def test_upsert_session_host_telnet_no_password(self):
|
||||
out, action = upsert_webcrt_session_host(
|
||||
self.db,
|
||||
name="tn",
|
||||
ip_address="10.6.6.6",
|
||||
port=23,
|
||||
protocol="telnet",
|
||||
)
|
||||
self.assertEqual(action, "created")
|
||||
row = self.db.query(ManagedNE).filter(ManagedNE.id == out.id).one()
|
||||
self.assertEqual(row.protocol, "telnet")
|
||||
self.assertEqual(row.password_enc, "")
|
||||
self.assertEqual(row.device_type, "generic")
|
||||
self.assertEqual(row.source, WEBCRT_NE_SOURCE)
|
||||
|
||||
def test_upsert_session_host_ssh_unsaved_password(self):
|
||||
out, action = upsert_webcrt_session_host(
|
||||
self.db,
|
||||
name="ssh1",
|
||||
ip_address="10.6.6.7",
|
||||
port=22,
|
||||
protocol="ssh",
|
||||
username="root",
|
||||
password="ephemeral",
|
||||
save_password=False,
|
||||
)
|
||||
self.assertEqual(action, "created")
|
||||
row = self.db.query(ManagedNE).filter(ManagedNE.id == out.id).one()
|
||||
self.assertEqual(row.username, "root")
|
||||
self.assertEqual(row.password_enc, "")
|
||||
|
||||
out2, action2 = upsert_webcrt_session_host(
|
||||
self.db,
|
||||
name="ssh1",
|
||||
ip_address="10.6.6.7",
|
||||
protocol="ssh",
|
||||
username="root",
|
||||
password="secret",
|
||||
save_password=True,
|
||||
)
|
||||
self.assertEqual(action2, "created")
|
||||
self.assertNotEqual(out2.id, out.id)
|
||||
self.assertEqual(out2.name, "ssh1 (1)")
|
||||
row2 = self.db.query(ManagedNE).filter(ManagedNE.id == out2.id).one()
|
||||
self.assertTrue(str(row2.password_enc or "").strip())
|
||||
|
||||
def test_session_host_same_ip_name_suffix(self):
|
||||
a, _ = upsert_webcrt_session_host(
|
||||
self.db, ip_address="10.5.5.5", protocol="ssh", username="u", password="p", save_password=True
|
||||
)
|
||||
b, _ = upsert_webcrt_session_host(
|
||||
self.db, ip_address="10.5.5.5", protocol="ssh", username="u", password="p", save_password=True
|
||||
)
|
||||
c, _ = upsert_webcrt_session_host(
|
||||
self.db, ip_address="10.5.5.5", protocol="telnet"
|
||||
)
|
||||
self.assertEqual(a.name, "10.5.5.5")
|
||||
self.assertEqual(b.name, "10.5.5.5 (1)")
|
||||
self.assertEqual(c.name, "10.5.5.5 (2)")
|
||||
self.assertEqual(a.ip_address, b.ip_address)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@ class CliAuthClassifyTests(unittest.TestCase):
|
|||
msg = format_cli_failure(AuthenticationException("target_auth_rejected: Permission denied"))
|
||||
self.assertTrue(msg.startswith("auth_rejected:"))
|
||||
|
||||
def test_authentication_exception_empty_message(self):
|
||||
class AuthenticationException(Exception):
|
||||
pass
|
||||
|
||||
msg = format_cli_failure(AuthenticationException())
|
||||
self.assertTrue(msg.startswith("auth_rejected:"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -101,6 +101,119 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
self.assertEqual(svc.prepare_bootstrap_output("banner\nR2#\n\nR2#"), "banner\nR2#")
|
||||
self.assertTrue(svc._is_prompt_only_echo("\r\nR2#\r\n", "R2#"))
|
||||
self.assertFalse(svc._is_prompt_only_echo("R2#show clock\r\n", "R2#"))
|
||||
self.assertTrue(svc._looks_like_login_prompt("Username:"))
|
||||
self.assertTrue(svc._looks_like_login_prompt("login:\nPassword:"))
|
||||
self.assertFalse(svc._looks_like_login_prompt("<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, "open_netmiko_connection")
|
||||
|
|
@ -190,6 +303,8 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
self.assertTrue(called_creds["hop_enabled"])
|
||||
self.assertEqual(called_creds["hop_vendor"], "bastion")
|
||||
self.assertIn("session_log", mock_open.call_args.kwargs)
|
||||
self.assertEqual(mock_open.call_args.kwargs.get("keepalive"), 0)
|
||||
self.assertEqual(out.get("keepalive_sec"), 0)
|
||||
fake.remote_conn.resize_pty.assert_called()
|
||||
self.assertEqual(out["ne_id"], "ne-hop")
|
||||
self.assertFalse(out.get("cli_hop")) # bastion hop is not vendor CLI hop guard
|
||||
|
|
@ -320,6 +435,17 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
{
|
||||
"username": "u",
|
||||
"password": "",
|
||||
"protocol": "ssh",
|
||||
"hop_enabled": False,
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
svc._webcrt_creds_ready(
|
||||
{
|
||||
"username": "",
|
||||
"password": "",
|
||||
"protocol": "telnet",
|
||||
"hop_enabled": False,
|
||||
}
|
||||
)
|
||||
|
|
@ -495,6 +621,17 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
self.assertEqual(svc._normalize_encoding("utf8"), "utf-8")
|
||||
self.assertEqual(svc._encode_text("测", "gbk")[:1], b"\xb2")
|
||||
|
||||
def test_linux_telnet_maps_to_generic_telnet(self) -> None:
|
||||
from netx_api.ne_netmiko import normalize_netmiko_device_type
|
||||
from netx_api.ne_session_factory import _netmiko_driver_class
|
||||
|
||||
dt = normalize_netmiko_device_type("linux", "telnet")
|
||||
self.assertEqual(dt, "generic_telnet")
|
||||
self.assertIsNotNone(_netmiko_driver_class(dt))
|
||||
self.assertEqual(normalize_netmiko_device_type("linux", "ssh"), "linux_ssh")
|
||||
self.assertEqual(normalize_netmiko_device_type("generic", "ssh"), "generic_termserver_ssh")
|
||||
self.assertIsNotNone(_netmiko_driver_class("generic_termserver_ssh"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "re
|
|||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import {
|
||||
applyKeywordHighlight,
|
||||
type KeywordHighlightConfig,
|
||||
} from "../utils/webcrtKeywordHighlight";
|
||||
|
||||
export type WebTerminalHandle = {
|
||||
clear: () => void;
|
||||
|
|
@ -16,15 +20,55 @@ export type WebTerminalHandle = {
|
|||
findPrevious: (term: string) => void;
|
||||
};
|
||||
|
||||
export type TermColors = {
|
||||
background: string;
|
||||
foreground: string;
|
||||
};
|
||||
|
||||
const DEFAULT_TERM_COLORS: TermColors = {
|
||||
background: "#0b1220",
|
||||
foreground: "#e2e8f0",
|
||||
};
|
||||
|
||||
function normalizeHex(hex: string, fallback: string): string {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex || "").trim());
|
||||
return m ? `#${m[1].toLowerCase()}` : fallback;
|
||||
}
|
||||
|
||||
function hexLuminance(hex: string): number {
|
||||
const n = normalizeHex(hex, "#000000").slice(1);
|
||||
const r = parseInt(n.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(n.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(n.slice(4, 6), 16) / 255;
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function xtermThemeFromColors(colors: TermColors) {
|
||||
const background = normalizeHex(colors.background, DEFAULT_TERM_COLORS.background);
|
||||
const foreground = normalizeHex(colors.foreground, DEFAULT_TERM_COLORS.foreground);
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
cursor: foreground,
|
||||
selectionBackground: hexLuminance(background) < 0.5 ? "#334155" : "#93c5fd",
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
wsUrl: string;
|
||||
title?: string;
|
||||
recording?: boolean;
|
||||
autoFocus?: boolean;
|
||||
encoding?: string;
|
||||
fontSize?: number;
|
||||
/** Terminal background / foreground (overrides legacy themeName). */
|
||||
termColors?: TermColors;
|
||||
/** @deprecated use termColors */
|
||||
themeName?: "dark" | "light";
|
||||
pasteDelayMs?: number;
|
||||
copyOnSelect?: boolean;
|
||||
onStatus?: (state: string, message?: string) => void;
|
||||
keywordHighlight?: KeywordHighlightConfig;
|
||||
onStatus?: (state: string, message?: string, phase?: string) => void;
|
||||
onReady?: () => void;
|
||||
onStdout?: (data: string) => void;
|
||||
};
|
||||
|
|
@ -103,14 +147,22 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
recording,
|
||||
autoFocus = true,
|
||||
encoding = "utf-8",
|
||||
fontSize = 13,
|
||||
termColors,
|
||||
themeName = "dark",
|
||||
pasteDelayMs,
|
||||
copyOnSelect,
|
||||
keywordHighlight,
|
||||
onStatus,
|
||||
onReady,
|
||||
onStdout,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const resolvedColors: TermColors = termColors ||
|
||||
(themeName === "light"
|
||||
? { background: "#ffffff", foreground: "#000000" }
|
||||
: DEFAULT_TERM_COLORS);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const termRef = useRef<Terminal | 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 autoFocusRef = useRef(autoFocus);
|
||||
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 copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect);
|
||||
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 [findOpen, setFindOpen] = useState(false);
|
||||
const [findQuery, setFindQuery] = useState("");
|
||||
|
|
@ -149,6 +206,25 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
encodingRef.current = encoding;
|
||||
}, [encoding]);
|
||||
|
||||
useEffect(() => {
|
||||
fontSizeRef.current = Math.max(10, Math.min(28, Number(fontSize) || 13));
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
term.options.fontSize = fontSizeRef.current;
|
||||
try {
|
||||
fitRef.current?.fit();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [fontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
termColorsRef.current = resolvedColors;
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
term.options.theme = xtermThemeFromColors(resolvedColors);
|
||||
}, [resolvedColors.background, resolvedColors.foreground]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pasteDelayMs != null) pasteDelayRef.current = pasteDelayMs;
|
||||
}, [pasteDelayMs]);
|
||||
|
|
@ -157,6 +233,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect;
|
||||
}, [copyOnSelect]);
|
||||
|
||||
keywordHighlightRef.current = keywordHighlight;
|
||||
|
||||
const focusTerminal = () => {
|
||||
try {
|
||||
termRef.current?.focus();
|
||||
|
|
@ -177,11 +255,37 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
sendJson({ type: "stdin", data });
|
||||
};
|
||||
|
||||
/** SecureCRT-like paste: send line-by-line with delay; show progress while sending. */
|
||||
/** Wait until device echoes (stdout after sentAt) or maxMs elapses — whichever first. */
|
||||
const waitForEchoOrTimeout = (maxMs: number, sentAt: number) =>
|
||||
new Promise<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) => {
|
||||
if (!data) return;
|
||||
const delay = pasteDelayRef.current;
|
||||
if (delay <= 0 || data.length < 8) {
|
||||
const maxDelay = pasteDelayRef.current;
|
||||
if (maxDelay <= 0 || data.length < 8) {
|
||||
sendStdinImmediate(data);
|
||||
return;
|
||||
}
|
||||
|
|
@ -193,10 +297,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const chunk = i < lines.length - 1 ? `${line}\r` : line;
|
||||
const sentAt = performance.now();
|
||||
if (chunk) sendStdinImmediate(chunk);
|
||||
setPasteStatus({ done: i + 1, total });
|
||||
if (i < lines.length - 1) {
|
||||
await new Promise((r) => window.setTimeout(r, delay));
|
||||
await waitForEchoOrTimeout(maxDelay, sentAt);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -274,15 +379,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontSize: fontSizeRef.current,
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
scrollback: 10000,
|
||||
theme: {
|
||||
background: "#0b1220",
|
||||
foreground: "#e2e8f0",
|
||||
cursor: "#e2e8f0",
|
||||
selectionBackground: "#334155",
|
||||
},
|
||||
theme: xtermThemeFromColors(termColorsRef.current),
|
||||
convertEol: true,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
|
|
@ -312,22 +412,31 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}, 50);
|
||||
});
|
||||
|
||||
// Guard against React StrictMode remount: the first WS teardown must not
|
||||
// report closed/error after a newer socket owns the terminal.
|
||||
let cancelled = false;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
onStatusRef.current?.("connecting");
|
||||
|
||||
const isActiveSocket = () => !cancelled && wsRef.current === ws;
|
||||
|
||||
const writeStdout = (raw: string) => {
|
||||
if (raw) term.write(raw);
|
||||
if (!raw || !isActiveSocket()) return;
|
||||
lastStdoutAtRef.current = performance.now();
|
||||
if (recordingRef.current) onStdoutRef.current?.(raw);
|
||||
term.write(applyKeywordHighlight(raw, keywordHighlightRef.current));
|
||||
};
|
||||
|
||||
const sendResize = () => {
|
||||
if (!isActiveSocket()) return;
|
||||
doFit();
|
||||
sendJson({ type: "resize", cols: term.cols, rows: term.rows });
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!isActiveSocket()) return;
|
||||
onStatusRef.current?.("open");
|
||||
sendResize();
|
||||
onReadyRef.current?.();
|
||||
|
|
@ -335,6 +444,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
if (!isActiveSocket()) return;
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
writeStdout(decodeBytes(ev.data, encodingRef.current));
|
||||
maybeFocus();
|
||||
|
|
@ -342,6 +452,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
}
|
||||
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
||||
void ev.data.arrayBuffer().then((buf) => {
|
||||
if (!isActiveSocket()) return;
|
||||
writeStdout(decodeBytes(buf, encodingRef.current));
|
||||
maybeFocus();
|
||||
});
|
||||
|
|
@ -353,6 +464,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
data?: string;
|
||||
state?: string;
|
||||
message?: string;
|
||||
phase?: string;
|
||||
};
|
||||
if (msg.type === "stdout" && typeof msg.data === "string") {
|
||||
writeStdout(msg.data);
|
||||
|
|
@ -360,7 +472,9 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
return;
|
||||
}
|
||||
if (msg.type === "status") {
|
||||
onStatusRef.current?.(String(msg.state || ""), msg.message);
|
||||
if (!isActiveSocket()) return;
|
||||
const phase = typeof msg.phase === "string" ? msg.phase : undefined;
|
||||
onStatusRef.current?.(String(msg.state || ""), msg.message, phase);
|
||||
if (msg.state === "connected" || msg.state === "connecting") {
|
||||
maybeFocus();
|
||||
return;
|
||||
|
|
@ -378,11 +492,14 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!isActiveSocket()) return;
|
||||
onStatusRef.current?.("error", "websocket_error");
|
||||
term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m");
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
// Intentional unmount/remount closes the socket; do not flip UI to "closed".
|
||||
if (!isActiveSocket()) return;
|
||||
onStatusRef.current?.("closed", `websocket_closed:${ev.code}`);
|
||||
if (!ev.wasClean) {
|
||||
term.writeln(`\r\n\x1b[33m[websocket closed code=${ev.code}]\x1b[0m`);
|
||||
|
|
@ -475,6 +592,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
ro?.observe(host);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(pingTimer);
|
||||
window.removeEventListener("resize", onWinResize);
|
||||
window.removeEventListener("keydown", onKeyDownCapture, true);
|
||||
|
|
@ -483,17 +601,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
ro?.disconnect();
|
||||
dataDisposable.dispose();
|
||||
selDisposable.dispose();
|
||||
if (wsRef.current === ws) wsRef.current = null;
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
wsRef.current = null;
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
fitRef.current = null;
|
||||
};
|
||||
}, [wsUrl, title]);
|
||||
// title is display-only; remounting on title change tears down a live WS.
|
||||
}, [wsUrl]);
|
||||
|
||||
const pasteFromClipboard = async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -905,7 +905,7 @@ const en = {
|
|||
sessionManager: "Device list",
|
||||
connect: "Connect",
|
||||
hint: "Pick an NE and open an interactive terminal in the browser. Credentials stay on the server.",
|
||||
hintCrt: "Click an NE on the left to open a terminal. Includes managed and UME inventory; search + paging for large fleets.",
|
||||
hintCrt: "Click an NE on the left to open a terminal. Includes managed, Sessions, and UME; or use New Session for a quick login.",
|
||||
filterKeyword: "Filter",
|
||||
filterKeywordPh: "Filter by name / IP / tag",
|
||||
empty: "No matching NEs. Switch source or refine the search.",
|
||||
|
|
@ -919,8 +919,25 @@ const en = {
|
|||
source: {
|
||||
all: "All",
|
||||
managed: "Managed",
|
||||
webcrt: "Sessions",
|
||||
ume: "UME",
|
||||
},
|
||||
newSession: {
|
||||
title: "New Session",
|
||||
hint: "Enter host and protocol. SSH prompts for username/password (editable on retry); Telnet logs in interactively. Duplicate names become (1), (2)…; same IP is allowed.",
|
||||
connect: "Connect",
|
||||
connecting: "Connecting…",
|
||||
protocol: "Protocol",
|
||||
host: "Hostname",
|
||||
port: "Port",
|
||||
sessionName: "Session name",
|
||||
sessionNamePh: "Optional; defaults to IP",
|
||||
authTitle: "SSH Authentication",
|
||||
savePassword: "Save password",
|
||||
ipRequired: "Hostname is required",
|
||||
userRequired: "Username is required",
|
||||
passwordRequired: "Password is required",
|
||||
},
|
||||
status: {
|
||||
idle: "Idle",
|
||||
connecting: "Connecting",
|
||||
|
|
@ -928,6 +945,47 @@ const en = {
|
|||
error: "Error",
|
||||
closed: "Closed",
|
||||
},
|
||||
phase: {
|
||||
creating: "Creating session",
|
||||
authenticating: "Authenticating",
|
||||
waitingPrompt: "Waiting for device prompt",
|
||||
hint: "Slow devices may take more than 10 seconds",
|
||||
},
|
||||
tabMenu: {
|
||||
close: "Close",
|
||||
closeOthers: "Close others",
|
||||
closeAll: "Close all",
|
||||
copyIp: "Copy IP",
|
||||
copyIpOk: "Copied {{ip}}",
|
||||
copyIpEmpty: "No IP on this tab",
|
||||
},
|
||||
treeMenu: {
|
||||
rename: "Rename",
|
||||
renameLabel: "Session name",
|
||||
renameSave: "Save",
|
||||
renameRequired: "Session name is required",
|
||||
renamed: "Session renamed",
|
||||
delete: "Delete session",
|
||||
deleteConfirm: "Delete session \"{{name}}\"? This cannot be undone.",
|
||||
deleted: "Session deleted",
|
||||
},
|
||||
keywordHl: {
|
||||
title: "Keyword Highlighting",
|
||||
enabled: "Enabled",
|
||||
newKeyword: "New keyword",
|
||||
newKeywordPh: "Keyword or regex",
|
||||
regex: "Regular expression",
|
||||
add: "Add",
|
||||
colKeyword: "Keyword",
|
||||
colRegex: "Regex",
|
||||
empty: "No keywords yet",
|
||||
moveUp: "Move up",
|
||||
moveDown: "Move down",
|
||||
remove: "Remove",
|
||||
caseSensitive: "Case sensitive",
|
||||
color: "Text color",
|
||||
saved: "Keyword highlighting saved",
|
||||
},
|
||||
actions: {
|
||||
reconnect: "Reconnect",
|
||||
recordLog: "Record log",
|
||||
|
|
@ -949,7 +1007,34 @@ const en = {
|
|||
recent: "Recent",
|
||||
favorite: "Toggle favorite",
|
||||
encoding: "Encoding",
|
||||
fontSize: "Font",
|
||||
fontSize: "Font size",
|
||||
theme: "Theme",
|
||||
themeDark: "Dark",
|
||||
themeLight: "Light",
|
||||
colorScheme: "Color scheme",
|
||||
backgroundColor: "Background",
|
||||
foregroundColor: "Font color",
|
||||
scheme: {
|
||||
dark: "Deep blue (default)",
|
||||
blackWhite: "Black on white",
|
||||
whiteBlack: "White on black",
|
||||
greenBlack: "Green on black",
|
||||
amberBlack: "Amber on black",
|
||||
custom: "Custom",
|
||||
},
|
||||
copyOnSelect: "Copy on select",
|
||||
pasteDelay: "Paste max line wait",
|
||||
pasteDelayOff: "No delay (bulk send)",
|
||||
keepalive: "SSH keepalive",
|
||||
keepaliveOff: "Off",
|
||||
keepaliveSec: "{{n}} sec",
|
||||
options: "Options",
|
||||
globalSessionOptions: "Global Session Options",
|
||||
sessionOptionsSave: "Save",
|
||||
sessionOptionsCancel: "Cancel",
|
||||
sessionOptionsSaved: "Global session options saved",
|
||||
disconnectBanner: "Session disconnected",
|
||||
disconnectBannerError: "Session error or connection lost",
|
||||
broadcastPh: "Send to all tabs…",
|
||||
buttonBarHint: "One per line: label|command",
|
||||
postLoginPrompt: "Post-login commands (one per line, this device)",
|
||||
|
|
@ -960,9 +1045,12 @@ const en = {
|
|||
uploaded: "Upload complete",
|
||||
},
|
||||
err: {
|
||||
deviceType: "Unsupported device type",
|
||||
ipConflictRestart: "Database still enforces unique IP; restart the netx API and retry",
|
||||
sessionLimit: "Session limit reached; close other terminals first",
|
||||
neNotFound: "Network element not found",
|
||||
credsIncomplete: "Incomplete username/password on this NE",
|
||||
authRejected: "Wrong username or password. Please try again.",
|
||||
cliProfile: "UME NEs require a CLI connect profile (default account / hop)",
|
||||
connectFailed: "Device login failed: {{detail}}",
|
||||
sftpHop: "SFTP does not support hop devices yet (direct SSH only)",
|
||||
|
|
|
|||
|
|
@ -902,7 +902,7 @@ const zh = {
|
|||
sessionManager: "设备列表",
|
||||
connect: "连接",
|
||||
hint: "选择网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。",
|
||||
hintCrt: "在左侧点击网元即可打开终端;支持托管与 UME 网元,可用搜索与分页浏览。",
|
||||
hintCrt: "在左侧点击网元即可打开终端;支持托管、会话与 UME;也可用菜单「新建会话」快速登录。",
|
||||
filterKeyword: "筛选",
|
||||
filterKeywordPh: "按名称 / IP / 标签筛选",
|
||||
empty: "没有匹配的网元。可切换来源或调整搜索关键字。",
|
||||
|
|
@ -916,8 +916,25 @@ const zh = {
|
|||
source: {
|
||||
all: "全部",
|
||||
managed: "托管",
|
||||
webcrt: "会话",
|
||||
ume: "UME",
|
||||
},
|
||||
newSession: {
|
||||
title: "新建会话",
|
||||
hint: "填写主机与协议后连接。SSH 弹出用户名/密码(可改);Telnet 进终端交互登录。同名会话自动加 (1)、(2)…,允许相同 IP。",
|
||||
connect: "连接",
|
||||
connecting: "连接中…",
|
||||
protocol: "协议",
|
||||
host: "主机",
|
||||
port: "端口",
|
||||
sessionName: "会话名",
|
||||
sessionNamePh: "可选,默认使用 IP",
|
||||
authTitle: "SSH 登录",
|
||||
savePassword: "保存密码",
|
||||
ipRequired: "请填写主机地址",
|
||||
userRequired: "请填写用户名",
|
||||
passwordRequired: "请填写密码",
|
||||
},
|
||||
status: {
|
||||
idle: "空闲",
|
||||
connecting: "连接中",
|
||||
|
|
@ -925,6 +942,47 @@ const zh = {
|
|||
error: "错误",
|
||||
closed: "已关闭",
|
||||
},
|
||||
phase: {
|
||||
creating: "创建会话",
|
||||
authenticating: "正在认证",
|
||||
waitingPrompt: "等待设备提示符",
|
||||
hint: "设备较慢时可能需要十几秒,请稍候",
|
||||
},
|
||||
tabMenu: {
|
||||
close: "关闭",
|
||||
closeOthers: "关闭其他",
|
||||
closeAll: "关闭全部",
|
||||
copyIp: "复制 IP",
|
||||
copyIpOk: "已复制 {{ip}}",
|
||||
copyIpEmpty: "该标签没有 IP",
|
||||
},
|
||||
treeMenu: {
|
||||
rename: "重命名",
|
||||
renameLabel: "会话名",
|
||||
renameSave: "保存",
|
||||
renameRequired: "请填写会话名",
|
||||
renamed: "会话已重命名",
|
||||
delete: "删除会话",
|
||||
deleteConfirm: "确定删除会话「{{name}}」?此操作不可恢复。",
|
||||
deleted: "会话已删除",
|
||||
},
|
||||
keywordHl: {
|
||||
title: "关键字突出显示",
|
||||
enabled: "启用",
|
||||
newKeyword: "新建关键字",
|
||||
newKeywordPh: "输入关键字或正则",
|
||||
regex: "正则表达式",
|
||||
add: "添加",
|
||||
colKeyword: "关键字",
|
||||
colRegex: "正则",
|
||||
empty: "尚未添加关键字",
|
||||
moveUp: "上移",
|
||||
moveDown: "下移",
|
||||
remove: "删除",
|
||||
caseSensitive: "区分大小写",
|
||||
color: "字符颜色",
|
||||
saved: "关键字突出显示已保存",
|
||||
},
|
||||
actions: {
|
||||
reconnect: "重新连接",
|
||||
recordLog: "记录日志",
|
||||
|
|
@ -947,6 +1005,33 @@ const zh = {
|
|||
favorite: "收藏/取消",
|
||||
encoding: "编码",
|
||||
fontSize: "字号",
|
||||
theme: "主题",
|
||||
themeDark: "深色",
|
||||
themeLight: "浅色",
|
||||
colorScheme: "配色方案",
|
||||
backgroundColor: "背景色",
|
||||
foregroundColor: "字体色",
|
||||
scheme: {
|
||||
dark: "深蓝(默认)",
|
||||
blackWhite: "白底黑字",
|
||||
whiteBlack: "黑底白字",
|
||||
greenBlack: "黑底绿字",
|
||||
amberBlack: "黑底琥珀",
|
||||
custom: "自定义",
|
||||
},
|
||||
copyOnSelect: "选中即复制",
|
||||
pasteDelay: "粘贴最大行间隔",
|
||||
pasteDelayOff: "无延迟(整块发送)",
|
||||
keepalive: "SSH Keepalive",
|
||||
keepaliveOff: "关闭",
|
||||
keepaliveSec: "{{n}} 秒",
|
||||
options: "选项",
|
||||
globalSessionOptions: "全局会话选项",
|
||||
sessionOptionsSave: "保存",
|
||||
sessionOptionsCancel: "取消",
|
||||
sessionOptionsSaved: "已保存全局会话选项",
|
||||
disconnectBanner: "会话已断开",
|
||||
disconnectBannerError: "会话出错或连接中断",
|
||||
broadcastPh: "发送到全部标签…",
|
||||
buttonBarHint: "每行:按钮名|命令",
|
||||
postLoginPrompt: "登录后自动执行(每行一条,仅本设备)",
|
||||
|
|
@ -957,9 +1042,12 @@ const zh = {
|
|||
uploaded: "上传成功",
|
||||
},
|
||||
err: {
|
||||
deviceType: "不支持的设备类型",
|
||||
ipConflictRestart: "数据库仍限制 IP 唯一,请重启 netx API 后再试",
|
||||
sessionLimit: "并发会话已达上限,请先关闭其他终端",
|
||||
neNotFound: "网元不存在",
|
||||
credsIncomplete: "网元账号或密码不完整",
|
||||
authRejected: "用户名或密码错误,请重新输入",
|
||||
cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)",
|
||||
connectFailed: "登录设备失败:{{detail}}",
|
||||
sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH",
|
||||
|
|
|
|||
|
|
@ -1883,20 +1883,354 @@ pre {
|
|||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__chrome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%);
|
||||
border-bottom: 1px solid #d7dee8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__new-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #b8c4d2;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--nm-brand);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__new-btn:hover {
|
||||
background: #e8f0fe;
|
||||
border-color: #9eb0c6;
|
||||
color: var(--nm-accent);
|
||||
}
|
||||
|
||||
.app-main .webcrt-sidebar__new-btn {
|
||||
border: 1px solid #b8c4d2;
|
||||
box-shadow: none;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.webcrt-menubar__item {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-menubar__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.webcrt-menubar__btn:hover,
|
||||
.webcrt-menubar__btn.is-open {
|
||||
background: #dbe4f0;
|
||||
color: var(--nm-brand);
|
||||
}
|
||||
|
||||
.app-main .webcrt-menubar__btn {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.webcrt-menubar__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
min-width: 180px;
|
||||
padding: 4px 0;
|
||||
background: #fff;
|
||||
border: 1px solid #c5d0de;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 20px rgba(15, 39, 68, 0.14);
|
||||
}
|
||||
|
||||
.webcrt-menubar__menu--end {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.webcrt-menubar__menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 7px 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #1e293b;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.webcrt-menubar__menu-item:hover {
|
||||
background: #e8f0fe;
|
||||
color: var(--nm-brand);
|
||||
}
|
||||
|
||||
.app-main .webcrt-menubar__menu-item {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.webcrt-session-opts {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.webcrt-session-opts__field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.webcrt-session-opts__field--check {
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.webcrt-session-opts__field--check input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.webcrt-session-opts__field select {
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #b8c4d2;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.webcrt-session-opts__hint {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.webcrt-session-opts__colors {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.webcrt-session-opts__color input[type="color"] {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 2px;
|
||||
border: 1px solid #b8c4d2;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webcrt-session-opts__preview {
|
||||
grid-column: 1 / -1;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #c5d0de;
|
||||
border-radius: 6px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.webcrt-session-opts-modal {
|
||||
width: min(460px, 100%);
|
||||
}
|
||||
|
||||
.webcrt-keyword-modal {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.webcrt-keyword-add {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.webcrt-keyword-add__regex {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
padding-bottom: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.webcrt-keyword-list-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 6px 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.webcrt-keyword-list-head {
|
||||
grid-column: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 48px;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.webcrt-keyword-list {
|
||||
grid-column: 1;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 160px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
border: 1px solid #c5d0de;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.webcrt-keyword-list__empty {
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.webcrt-keyword-list__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 48px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.webcrt-keyword-list__row:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.webcrt-keyword-list__row.is-selected {
|
||||
background: #e8f0fe;
|
||||
color: var(--nm-brand);
|
||||
}
|
||||
|
||||
.app-main .webcrt-keyword-list__row {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.webcrt-keyword-list__pattern {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.webcrt-keyword-list-actions {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.webcrt-keyword-list-actions button {
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.webcrt-keyword-footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.webcrt-keyword-color input[type="color"] {
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
padding: 2px;
|
||||
border: 1px solid #b8c4d2;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__search {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #d7dee8;
|
||||
.webcrt-sidebar__chrome .webcrt-sidebar__search {
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__search input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 32px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #b8c4d2;
|
||||
border-radius: 6px;
|
||||
|
|
@ -1904,11 +2238,15 @@ pre {
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
.webcrt-sidebar__chrome .webcrt-source-tabs {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.webcrt-source-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2px;
|
||||
margin: 0 10px 8px;
|
||||
margin: 0;
|
||||
padding: 2px;
|
||||
background: #d7dee8;
|
||||
border: 1px solid var(--nm-nav-border);
|
||||
|
|
@ -1916,21 +2254,41 @@ pre {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-source-tabs--4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.webcrt-new-session-modal,
|
||||
.webcrt-auth-modal {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.webcrt-auth-error {
|
||||
color: #b91c1c;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.webcrt-source-tabs__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--nm-muted);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-main .webcrt-source-tabs__btn,
|
||||
|
|
@ -1996,7 +2354,7 @@ pre {
|
|||
background: transparent;
|
||||
min-height: 0;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0 8px 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.webcrt-tree__folder {
|
||||
|
|
@ -2202,6 +2560,20 @@ pre {
|
|||
box-shadow: 0 1px 0 #fff;
|
||||
}
|
||||
|
||||
.webcrt-tabs__item.is-dead {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.webcrt-tabs__item.is-dead.is-active {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.webcrt-tabs__item.is-connecting {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/* span close mark — avoid global `button` border/padding chrome */
|
||||
.webcrt-tabs__close {
|
||||
display: inline;
|
||||
|
|
@ -2271,6 +2643,83 @@ pre {
|
|||
color: var(--nm-accent);
|
||||
}
|
||||
|
||||
.webcrt-action-btn.is-warn {
|
||||
background: #fef2f2;
|
||||
border-color: #fca5a5;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.webcrt-action-btn.is-warn:hover {
|
||||
background: #fee2e2;
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
.webcrt-connect-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
background: #1e3a5f;
|
||||
border-bottom: 1px solid #274a73;
|
||||
color: #dbeafe;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.webcrt-connect-banner__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.webcrt-connect-banner__text strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.webcrt-connect-banner__text span {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.webcrt-disconnect-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
background: #7f1d1d;
|
||||
border-bottom: 1px solid #991b1b;
|
||||
color: #fee2e2;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.webcrt-disconnect-banner__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.webcrt-disconnect-banner__text strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.webcrt-disconnect-banner__text span {
|
||||
opacity: 0.85;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.webcrt-disconnect-banner .webcrt-action-btn.is-warn {
|
||||
background: #fff;
|
||||
border-color: #fecaca;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-main__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
|
@ -2290,13 +2739,33 @@ pre {
|
|||
}
|
||||
|
||||
.webcrt-main__pane .webcrt-term,
|
||||
.webcrt-main__pane .webcrt-term-wrap {
|
||||
.webcrt-main__pane .webcrt-term-wrap,
|
||||
.webcrt-main__pane .webcrt-ssh-login {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: auto !important;
|
||||
min-height: 240px !important;
|
||||
}
|
||||
|
||||
.webcrt-ssh-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.webcrt-ssh-login__save {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||
color: #cbd5e1;
|
||||
background: #0b1220;
|
||||
}
|
||||
|
||||
.webcrt-ssh-login .ssh-login-term {
|
||||
flex: 1;
|
||||
min-height: 200px !important;
|
||||
}
|
||||
|
||||
.webcrt-main__empty,
|
||||
.webcrt-main__placeholder {
|
||||
height: 100%;
|
||||
|
|
@ -2415,6 +2884,20 @@ pre {
|
|||
background: #334155;
|
||||
}
|
||||
|
||||
.webcrt-ctx button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.webcrt-ctx button:disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.webcrt-tab-ctx {
|
||||
border-radius: 6px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.webcrt-tree__row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
|
@ -2625,10 +3108,6 @@ pre {
|
|||
.webcrt-main {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.webcrt-source-tabs {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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> => {
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
if (res.status === 401) {
|
||||
|
|
@ -111,9 +151,9 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error(String(data.detail || "unauthorized"));
|
||||
throw new ApiRequestError(401, data.detail || "unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
|
||||
return data as T;
|
||||
};
|
||||
|
||||
|
|
@ -126,9 +166,9 @@ export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error(String(data.detail || "unauthorized"));
|
||||
throw new ApiRequestError(401, data.detail || "unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
|
||||
return data as T;
|
||||
};
|
||||
|
||||
|
|
@ -137,9 +177,9 @@ export const apiDelete = async <T,>(path: string): Promise<T> => {
|
|||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error(String(data.detail || "unauthorized"));
|
||||
throw new ApiRequestError(401, data.detail || "unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
|
||||
return data as T;
|
||||
};
|
||||
|
||||
|
|
@ -152,9 +192,9 @@ export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error(String(data.detail || "unauthorized"));
|
||||
throw new ApiRequestError(401, data.detail || "unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
if (!res.ok) throw new ApiRequestError(res.status, data.detail || `${res.status} ${path}`);
|
||||
return data as T;
|
||||
};
|
||||
|
||||
|
|
@ -471,10 +511,34 @@ export const createWebcrtSession = (body: {
|
|||
cols?: number;
|
||||
rows?: number;
|
||||
encoding?: string;
|
||||
keepalive_sec?: number;
|
||||
post_login_commands?: string[];
|
||||
async_connect?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}) => apiPost<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) =>
|
||||
apiDelete<{ ok: boolean; session_id: string; closed: boolean }>(
|
||||
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
|
||||
|
|
@ -565,7 +629,7 @@ export const fetchCliProfiles = () =>
|
|||
apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles");
|
||||
|
||||
export const fetchCliTargets = (params: {
|
||||
source?: "managed" | "ume" | "all";
|
||||
source?: "managed" | "ume" | "webcrt" | "all";
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
|
|
|
|||
|
|
@ -257,6 +257,9 @@ export type CliTargetItem = {
|
|||
ne_type?: string;
|
||||
vendor?: string;
|
||||
device_type?: string;
|
||||
protocol?: string;
|
||||
username?: string;
|
||||
has_password?: boolean;
|
||||
connect_status: string;
|
||||
cli_profile_ready?: boolean;
|
||||
};
|
||||
|
|
|
|||
189
web/src/utils/webcrtKeywordHighlight.ts
Normal file
189
web/src/utils/webcrtKeywordHighlight.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue