feat(webcrt): add CRT-style browser terminal for managed and UME NEs

Ship an ops WebCRT module with xterm.js UI, WebSocket session bridge reusing hop/bastion login, searchable paged targets, and session audit limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-29 17:23:45 +08:00
parent 88479245bb
commit 873f2bfe34
21 changed files with 1868 additions and 45 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@ dist/
scripts/.run/
ume/
data/ne_collections/
data/webcrt/

View file

@ -23,7 +23,6 @@ from .ne_service import (
_normalize_hop_vendor,
_normalize_protocol,
_require_crypto,
list_managed_ne,
)
@ -263,32 +262,55 @@ def list_cli_targets(
if src not in ("managed", "ume", "all"):
raise HTTPException(status_code=400, detail="invalid_source")
ready = cli_profile_ready(db)
items: list[dict[str, Any]] = []
total = 0
page = max(1, int(page or 1))
page_size = max(1, min(500, int(page_size or 50)))
offset = (page - 1) * page_size
kw = str(keyword or "").strip()
if src in ("managed", "all"):
managed = list_managed_ne(db, keyword=keyword, page=page, page_size=page_size)
total += int(managed["total"])
for row in managed["items"]:
items.append(
CliTargetOut(
source="managed",
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),
connect_status=str(row.connect_status),
cli_profile_ready=True,
).model_dump()
def _managed_item(row: Any) -> dict[str, Any]:
return CliTargetOut(
source="managed",
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),
connect_status=str(row.connect_status),
cli_profile_ready=True,
).model_dump()
def _ume_item(inv: UmeInventoryNE, ov: UmeCliOverride | None) -> dict[str, Any]:
return CliTargetOut(
source="ume",
id=str(inv.ne_id),
ume_ne_id=str(inv.ne_id),
name=str(inv.user_label or inv.ne_name or inv.host_name or inv.ip_address or inv.ne_id),
ip_address=str(inv.ip_address or ""),
ne_type=str(inv.ne_type or ""),
vendor=str(inv.vendor or ""),
connect_status=str(ov.connect_status if ov else "unknown"),
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())
if src in ("ume", "all"):
def _ume_query():
stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin(
UmeCliOverride, UmeInventoryNE.ne_id == UmeCliOverride.ume_ne_id
)
kw = str(keyword or "").strip()
if kw:
like = f"%{kw}%"
stmt = stmt.filter(
@ -298,28 +320,41 @@ def list_cli_targets(
| UmeInventoryNE.ip_address.ilike(like)
| UmeInventoryNE.host_name.ilike(like)
)
ume_total = stmt.count()
total += ume_total if src == "ume" else ume_total
rows = (
stmt.order_by(UmeInventoryNE.ne_id.asc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
for inv, ov in rows:
items.append(
CliTargetOut(
source="ume",
id=str(inv.ne_id),
ume_ne_id=str(inv.ne_id),
name=str(inv.user_label or inv.ne_name or inv.host_name or inv.ip_address or inv.ne_id),
ip_address=str(inv.ip_address or ""),
ne_type=str(inv.ne_type or ""),
vendor=str(inv.vendor or ""),
connect_status=str(ov.connect_status if ov else "unknown"),
cli_profile_ready=ready,
).model_dump()
)
return stmt.order_by(UmeInventoryNE.ne_id.asc())
if src == "managed":
mq = _managed_query()
total = int(mq.count())
rows = mq.offset(offset).limit(page_size).all()
items = [_managed_item(x) for x in rows]
return {"items": items, "total": total, "page": page, "page_size": page_size}
if src == "ume":
uq = _ume_query()
total = int(uq.count())
rows = uq.offset(offset).limit(page_size).all()
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()
uq = _ume_query()
m_total = int(mq.count())
u_total = int(uq.count())
total = m_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))
else:
u_off = offset - m_total
for inv, ov in uq.offset(u_off).limit(page_size).all():
items.append(_ume_item(inv, ov))
return {"items": items, "total": total, "page": page, "page_size": page_size}

View file

@ -74,6 +74,12 @@ class Settings(BaseSettings):
ne_collection_data_dir: str = "data/ne_collections"
# Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec).
ne_exec_max_commands: int = 5
# WebCRT interactive terminal sessions
webcrt_max_sessions: int = 20
webcrt_idle_timeout_sec: int = 1800
webcrt_connect_timeout_sec: int = 90
webcrt_attach_timeout_sec: int = 60
webcrt_data_dir: str = "data/webcrt"
settings = Settings()

View file

@ -24,6 +24,7 @@ from .db import Base, SessionLocal, engine, get_db
from .collection_router import router as collection_router
from .cli_router import router as cli_router
from .managed_ne_router import router as managed_ne_router
from .webcrt_router import router as webcrt_router
from .importer import aggregate_alarms, import_alarm_excel, query_alarms
from .models import (
AiAnalyzeHistory,
@ -123,6 +124,7 @@ app = FastAPI(title="netx ops tool", version="0.1.0")
app.include_router(managed_ne_router)
app.include_router(cli_router)
app.include_router(collection_router)
app.include_router(webcrt_router)
parser_cfg = load_parser_config()
_UME_CLIENT_SINGLETON = UMEClient(
token_loader=lambda: load_shared_token(),

170
netx_api/webcrt_router.py Normal file
View file

@ -0,0 +1,170 @@
"""WebCRT HTTP + WebSocket routes."""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .db import get_db
from .webcrt_service import (
close_session,
create_session,
list_sessions,
mark_attached,
)
_log = logging.getLogger("netx.webcrt.router")
router = APIRouter(prefix="/v1/webcrt", tags=["webcrt"])
class WebcrtSessionCreate(BaseModel):
ne_id: str | None = Field(default=None)
ume_ne_id: str | None = Field(default=None)
cols: int = Field(default=80, ge=20, le=500)
rows: int = Field(default=24, ge=5, le=200)
def _client_label(request: Request | None = None, websocket: WebSocket | None = None) -> str:
host = ""
if request is not None:
host = request.client.host if request.client else ""
elif websocket is not None:
host = websocket.client.host if websocket.client else ""
return str(host or "")
@router.get("/sessions")
def api_list_sessions() -> dict[str, Any]:
return list_sessions()
@router.post("/sessions")
def api_create_session(
body: WebcrtSessionCreate,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
mid = str(body.ne_id or "").strip()
uid = str(body.ume_ne_id or "").strip()
if bool(mid) == bool(uid):
raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required")
return create_session(
db,
ne_id=mid or None,
ume_ne_id=uid or None,
cols=body.cols,
rows=body.rows,
client=_client_label(request=request),
)
@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))
@router.websocket("/sessions/{session_id}/ws")
async def websocket_session(websocket: WebSocket, session_id: str) -> None:
await websocket.accept()
try:
sess = mark_attached(session_id)
except HTTPException as exc:
await websocket.send_json({"type": "status", "state": "error", "message": str(exc.detail)})
await websocket.close(code=4404 if exc.status_code == 404 else 4409)
return
await websocket.send_json(
{
"type": "status",
"state": "connected",
"session_id": sess.session_id,
"ne_id": sess.ne_id,
"ne_name": sess.ne_name,
"ne_ip": sess.ne_ip,
"protocol": sess.protocol,
"cols": sess.cols,
"rows": sess.rows,
}
)
stop = asyncio.Event()
async def pump_stdout() -> None:
loop = asyncio.get_running_loop()
while not stop.is_set():
chunk = await loop.run_in_executor(None, sess.out_queue.get)
if chunk is None:
stop.set()
try:
await websocket.send_json(
{
"type": "status",
"state": "closed",
"message": sess.close_reason or "device_closed",
}
)
except Exception:
pass
break
try:
text = chunk.decode("utf-8", errors="replace")
await websocket.send_json({"type": "stdout", "data": text})
except Exception:
stop.set()
break
reader_task = asyncio.create_task(pump_stdout())
try:
while not stop.is_set():
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
# Treat plain text as stdin.
msg = {"type": "stdin", "data": raw}
mtype = str(msg.get("type") or "").strip().lower()
if mtype == "stdin":
data = msg.get("data")
if data is None:
continue
try:
await asyncio.get_running_loop().run_in_executor(
None, sess.write_stdin, str(data)
)
except Exception as exc:
await websocket.send_json(
{"type": "status", "state": "error", "message": f"write_failed:{exc}"}
)
break
elif mtype == "resize":
cols = int(msg.get("cols") or sess.cols)
rows = int(msg.get("rows") or sess.rows)
await asyncio.get_running_loop().run_in_executor(None, sess.resize, cols, rows)
elif mtype == "ping":
sess.touch()
await websocket.send_json({"type": "pong"})
elif mtype == "close":
break
except WebSocketDisconnect:
_log.info("webcrt ws disconnected session=%s", session_id)
except Exception:
_log.exception("webcrt ws error session=%s", session_id)
finally:
stop.set()
reader_task.cancel()
try:
await reader_task
except Exception:
pass
close_session(
session_id,
reason="ws_disconnect",
client=_client_label(websocket=websocket),
)

378
netx_api/webcrt_service.py Normal file
View file

@ -0,0 +1,378 @@
"""Interactive WebCRT sessions: bridge browser WebSocket <-> Netmiko device channel."""
from __future__ import annotations
import json
import logging
import queue
import threading
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import HTTPException
from netmiko import ConnectHandler
from sqlalchemy.orm import Session
from .config import settings
from .ne_crypto import CredentialCryptoError
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
_log = logging.getLogger("netx.webcrt")
_sessions_lock = threading.Lock()
_sessions: dict[str, "WebcrtSession"] = {}
_reaper_started = False
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _utc_iso() -> str:
return _utc_now().isoformat()
def webcrt_data_root() -> Path:
root = Path(str(settings.webcrt_data_dir or "data/webcrt"))
root.mkdir(parents=True, exist_ok=True)
return root.resolve()
def _audit(event: str, **fields: Any) -> None:
record = {"ts": _utc_iso(), "event": event, **fields}
try:
path = webcrt_data_root() / "audit.jsonl"
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception:
_log.exception("webcrt audit write failed")
_log.info("webcrt.%s %s", event, {k: v for k, v in fields.items() if k != "detail"})
@dataclass
class WebcrtSession:
session_id: str
ne_id: str
ne_name: str
ne_ip: str
protocol: str
cols: int
rows: int
conn: ConnectHandler | None = None
created_at: float = field(default_factory=time.time)
last_activity: float = field(default_factory=time.time)
attached: bool = False
closed: bool = False
close_reason: str = ""
out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue)
_reader: threading.Thread | None = field(default=None, repr=False)
_write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
def touch(self) -> None:
self.last_activity = time.time()
def write_stdin(self, data: str) -> None:
if self.closed or self.conn is None:
raise RuntimeError("session_closed")
text = str(data or "")
if not text:
return
with self._write_lock:
self.conn.write_channel(text)
self.touch()
def resize(self, cols: int, rows: int) -> None:
if self.closed or self.conn is None:
return
c = max(20, min(500, int(cols or 80)))
r = max(5, min(200, int(rows or 24)))
self.cols = c
self.rows = r
channel = getattr(self.conn, "remote_conn", None)
if channel is not None and hasattr(channel, "resize_pty"):
try:
channel.resize_pty(width=c, height=r)
except Exception:
_log.debug("resize_pty failed session=%s", self.session_id, exc_info=True)
self.touch()
def start_reader(self) -> None:
if self._reader and self._reader.is_alive():
return
self._reader = threading.Thread(
target=self._reader_loop,
name=f"webcrt-reader-{self.session_id[:8]}",
daemon=True,
)
self._reader.start()
def _reader_loop(self) -> None:
conn = self.conn
if conn is None:
self.out_queue.put(None)
return
channel = getattr(conn, "remote_conn", None)
try:
while not self.closed:
chunk = b""
try:
if channel is not None and hasattr(channel, "recv_ready") and hasattr(channel, "recv"):
if channel.recv_ready():
chunk = channel.recv(4096)
if not chunk:
break
elif hasattr(channel, "exit_status_ready") and channel.exit_status_ready():
break
else:
time.sleep(0.04)
continue
else:
text = conn.read_channel()
if text:
chunk = text.encode("utf-8", errors="replace")
else:
time.sleep(0.04)
continue
except Exception as exc:
if self.closed:
break
_log.debug("webcrt reader error session=%s: %s", self.session_id, exc)
time.sleep(0.1)
continue
if chunk:
self.touch()
self.out_queue.put(chunk)
finally:
self.out_queue.put(None)
def close(self, reason: str = "closed") -> None:
if self.closed:
return
self.closed = True
self.close_reason = reason or "closed"
try:
close_netmiko_connection(self.conn)
except Exception:
pass
self.conn = None
try:
self.out_queue.put_nowait(None)
except Exception:
pass
def _ensure_reaper() -> None:
global _reaper_started
with _sessions_lock:
if _reaper_started:
return
_reaper_started = True
t = threading.Thread(target=_reaper_loop, name="webcrt-reaper", daemon=True)
t.start()
def _reaper_loop() -> None:
while True:
try:
_reap_sessions()
except Exception:
_log.exception("webcrt reaper failed")
time.sleep(5)
def _reap_sessions() -> None:
idle = max(60, int(settings.webcrt_idle_timeout_sec or 1800))
attach = max(10, int(settings.webcrt_attach_timeout_sec or 60))
now = time.time()
to_close: list[tuple[WebcrtSession, str]] = []
with _sessions_lock:
for sess in list(_sessions.values()):
if sess.closed:
_sessions.pop(sess.session_id, None)
continue
if not sess.attached and (now - sess.created_at) > attach:
to_close.append((sess, "attach_timeout"))
elif (now - sess.last_activity) > idle:
to_close.append((sess, "idle_timeout"))
for sess, reason in to_close:
close_session(sess.session_id, reason=reason)
def active_session_count() -> int:
with _sessions_lock:
return sum(1 for s in _sessions.values() if not s.closed)
def get_session(session_id: str) -> WebcrtSession | None:
with _sessions_lock:
sess = _sessions.get(session_id)
if sess is None or sess.closed:
return None
return sess
def create_session(
db: Session,
*,
ne_id: str | None = None,
ume_ne_id: str | None = None,
cols: int = 80,
rows: int = 24,
client: str = "",
) -> dict[str, Any]:
from .cli_resolve import resolve_cli_target
_ensure_reaper()
max_sessions = max(1, int(settings.webcrt_max_sessions or 20))
if active_session_count() >= max_sessions:
raise HTTPException(status_code=429, detail="webcrt_session_limit")
mid = str(ne_id or "").strip()
uid = str(ume_ne_id or "").strip()
try:
creds, device = resolve_cli_target(db, managed_ne_id=mid or None, ume_ne_id=uid or None)
except HTTPException:
raise
except CredentialCryptoError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "credential_crypto_error") from exc
except Exception as exc:
raise HTTPException(status_code=400, detail=f"credential_error:{exc}") from exc
if not str(creds.get("username") or "").strip() or not str(creds.get("password") or ""):
raise HTTPException(status_code=400, detail="credentials_incomplete")
session_id = str(uuid.uuid4())
c = max(20, min(500, int(cols or 80)))
r = max(5, min(200, int(rows or 24)))
connect_timeout = max(30, int(settings.webcrt_connect_timeout_sec or 90))
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")
try:
conn = open_netmiko_connection(creds, session_timeout=connect_timeout)
except Exception as exc:
_audit(
"session_open_failed",
session_id=session_id,
ne_id=target_id,
ne_ip=target_ip,
source=str(device.get("source") or ""),
client=client or "",
error=str(exc)[:500],
)
raise HTTPException(status_code=502, detail=f"connect_failed:{exc}") from exc
channel = getattr(conn, "remote_conn", None)
if channel is not None and hasattr(channel, "resize_pty"):
try:
channel.resize_pty(width=c, height=r)
except Exception:
pass
try:
leftover = conn.read_channel()
except Exception:
leftover = ""
sess = WebcrtSession(
session_id=session_id,
ne_id=target_id,
ne_name=target_name,
ne_ip=target_ip,
protocol=protocol,
cols=c,
rows=r,
conn=conn,
)
if leftover:
sess.out_queue.put(str(leftover).encode("utf-8", errors="replace"))
sess.start_reader()
with _sessions_lock:
_sessions[session_id] = sess
_audit(
"session_created",
session_id=session_id,
ne_id=sess.ne_id,
ne_name=sess.ne_name,
ne_ip=sess.ne_ip,
protocol=sess.protocol,
source=str(device.get("source") or ""),
hop_enabled=bool(creds.get("hop_enabled")),
hop_vendor=str(creds.get("hop_vendor") or "") if creds.get("hop_enabled") else "",
client=client or "",
active=active_session_count(),
)
return {
"session_id": session_id,
"ne_id": sess.ne_id,
"ne_name": sess.ne_name,
"ne_ip": sess.ne_ip,
"source": str(device.get("source") or ""),
"protocol": sess.protocol,
"cols": sess.cols,
"rows": sess.rows,
"ws_path": f"/v1/webcrt/sessions/{session_id}/ws",
}
def mark_attached(session_id: str) -> WebcrtSession:
sess = get_session(session_id)
if sess is None:
raise HTTPException(status_code=404, detail="webcrt_session_not_found")
if sess.attached:
raise HTTPException(status_code=409, detail="webcrt_session_already_attached")
sess.attached = True
sess.touch()
_audit("session_attached", session_id=session_id, ne_id=sess.ne_id, ne_ip=sess.ne_ip)
return sess
def close_session(session_id: str, *, reason: str = "closed", client: str = "") -> dict[str, Any]:
with _sessions_lock:
sess = _sessions.pop(session_id, None)
if sess is None:
return {"ok": True, "session_id": session_id, "closed": False}
if not sess.closed:
sess.close(reason)
_audit(
"session_closed",
session_id=session_id,
ne_id=sess.ne_id,
ne_ip=sess.ne_ip,
reason=reason,
client=client or "",
active=active_session_count(),
)
return {"ok": True, "session_id": session_id, "closed": True, "reason": reason}
def list_sessions() -> dict[str, Any]:
with _sessions_lock:
items = [
{
"session_id": s.session_id,
"ne_id": s.ne_id,
"ne_name": s.ne_name,
"ne_ip": s.ne_ip,
"protocol": s.protocol,
"attached": s.attached,
"created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(),
"last_activity": datetime.fromtimestamp(s.last_activity, tz=timezone.utc).isoformat(),
}
for s in _sessions.values()
if not s.closed
]
return {
"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)),
"items": items,
}

185
tests/test_webcrt.py Normal file
View file

@ -0,0 +1,185 @@
"""Unit tests for WebCRT session service (mocked device connection)."""
from __future__ import annotations
import time
import unittest
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from netx_api import webcrt_service as svc
class _FakeConn:
def __init__(self) -> None:
self.written: list[str] = []
self.remote_conn = MagicMock()
self.remote_conn.recv_ready.return_value = False
self.remote_conn.exit_status_ready.return_value = False
self.remote_conn.resize_pty = MagicMock()
def write_channel(self, data: str) -> None:
self.written.append(data)
def read_channel(self) -> str:
return ""
def disconnect(self) -> None:
return None
class WebcrtServiceTests(unittest.TestCase):
def setUp(self) -> None:
with svc._sessions_lock:
for s in list(svc._sessions.values()):
s.close("test_cleanup")
svc._sessions.clear()
def tearDown(self) -> None:
self.setUp()
def test_session_write_resize_and_close(self) -> None:
conn = _FakeConn()
sess = svc.WebcrtSession(
session_id="s1",
ne_id="ne1",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
conn=conn, # type: ignore[arg-type]
)
sess.write_stdin("show ver\n")
self.assertEqual(conn.written, ["show ver\n"])
sess.resize(120, 40)
conn.remote_conn.resize_pty.assert_called_with(width=120, height=40)
sess.close("test")
self.assertTrue(sess.closed)
@patch.object(svc, "_audit")
@patch.object(svc, "open_netmiko_connection")
@patch("netx_api.cli_resolve.resolve_cli_target")
def test_create_session_limit(
self,
mock_resolve: MagicMock,
mock_open: MagicMock,
_mock_audit: MagicMock,
) -> None:
mock_resolve.return_value = (
{
"username": "u",
"password": "p",
"hop_enabled": False,
"ip_address": "10.0.0.1",
"protocol": "ssh",
},
{
"id": "ne-a",
"name": "A",
"ip_address": "10.0.0.1",
"protocol": "ssh",
"source": "managed",
},
)
mock_open.side_effect = lambda *a, **k: _FakeConn()
db = MagicMock()
with patch.object(svc.settings, "webcrt_max_sessions", 1):
out = svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test")
self.assertIn("session_id", out)
with self.assertRaises(HTTPException) as ctx:
svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test")
self.assertEqual(ctx.exception.status_code, 429)
@patch.object(svc, "_audit")
@patch.object(svc, "open_netmiko_connection")
@patch("netx_api.cli_resolve.resolve_cli_target")
def test_create_session_passes_hop_creds(
self,
mock_resolve: MagicMock,
mock_open: MagicMock,
_mock_audit: MagicMock,
) -> None:
mock_resolve.return_value = (
{
"username": "u",
"password": "p",
"hop_enabled": True,
"hop_vendor": "bastion",
"hop_host": "jump.example",
"ip_address": "10.0.0.2",
"protocol": "ssh",
},
{
"id": "ne-hop",
"name": "HopNE",
"ip_address": "10.0.0.2",
"protocol": "ssh",
"source": "managed",
},
)
fake = _FakeConn()
mock_open.return_value = fake
db = MagicMock()
out = svc.create_session(db, ne_id="ne-hop", cols=100, rows=30, client="test")
mock_open.assert_called_once()
called_creds = mock_open.call_args.args[0]
self.assertTrue(called_creds["hop_enabled"])
self.assertEqual(called_creds["hop_vendor"], "bastion")
fake.remote_conn.resize_pty.assert_called()
self.assertEqual(out["ne_id"], "ne-hop")
sess = svc.get_session(out["session_id"])
assert sess is not None
sess.write_stdin("\n")
self.assertEqual(fake.written, ["\n"])
svc.close_session(out["session_id"], reason="test")
@patch.object(svc, "_audit")
def test_attach_timeout_reaper(self, _mock_audit: MagicMock) -> None:
conn = _FakeConn()
sess = svc.WebcrtSession(
session_id="stale",
ne_id="ne1",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
conn=conn, # type: ignore[arg-type]
)
sess.created_at = time.time() - 120
with svc._sessions_lock:
svc._sessions["stale"] = sess
with patch.object(svc.settings, "webcrt_attach_timeout_sec", 30):
with patch.object(svc.settings, "webcrt_idle_timeout_sec", 99999):
svc._reap_sessions()
self.assertIsNone(svc.get_session("stale"))
@patch.object(svc, "_audit")
def test_idle_timeout_reaper(self, _mock_audit: MagicMock) -> None:
conn = _FakeConn()
sess = svc.WebcrtSession(
session_id="idle",
ne_id="ne1",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
conn=conn, # type: ignore[arg-type]
)
sess.attached = True
sess.last_activity = time.time() - 9999
with svc._sessions_lock:
svc._sessions["idle"] = sess
with patch.object(svc.settings, "webcrt_attach_timeout_sec", 99999):
with patch.object(svc.settings, "webcrt_idle_timeout_sec", 60):
svc._reap_sessions()
self.assertIsNone(svc.get_session("idle"))
if __name__ == "__main__":
unittest.main()

View file

@ -35,6 +35,7 @@ src/
| `/ume` | UME 同步 | `ume` |
| `/ne` | 网元管理 | `managed-ne` |
| `/collect` | 批量采集 | `collect` |
| `/webcrt` | WebCRT 终端 | `webcrt` |
**新增模块只需改 `config/modules.ts`:**
@ -75,6 +76,15 @@ src/
- 采集日志目录:`NETX_NE_COLLECTION_DATA_DIR`(默认 `data/ne_collections`)
- 命令每行一条,`#` 为注释;输出格式与旧版 NetX 采集 `.txt` 一致
## WebCRT
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
- 目标列表复用 `/v1/cli/targets`(托管 + UME,搜索分页;`source=all|managed|ume`)
- 凭据:托管走网元自身账号;UME 走 CLI 连接模板(`resolve_cli_target`)
- 前端:CRT 风格左右分栏(会话管理 + 多标签终端)
- 审计:`NETX_WEBCRT_DATA_DIR`(默认 `data/webcrt/audit.jsonl`)
- 限流:`NETX_WEBCRT_MAX_SESSIONS`、`NETX_WEBCRT_IDLE_TIMEOUT_SEC`
## Toast
- 使用 `ToastProvider`(`main.tsx`)+ `useToast()`

17
web/package-lock.json generated
View file

@ -9,6 +9,8 @@
"version": "0.0.0",
"dependencies": {
"@tanstack/react-query": "^5.100.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
@ -1199,6 +1201,21 @@
}
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",

View file

@ -14,6 +14,8 @@
},
"dependencies": {
"@tanstack/react-query": "^5.100.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"

View file

@ -6,6 +6,7 @@ import { WorkbenchPage } from "./pages/WorkbenchPage";
import { CollectPage } from "./pages/CollectPage";
import { NePage } from "./pages/NePage";
import { UmePage } from "./pages/UmePage";
import { WebcrtPage } from "./pages/WebcrtPage";
import { fetchIntegrationStatus } from "./services/api";
function App() {
@ -49,6 +50,7 @@ function App() {
<Route path="/ume" element={<UmePage />} />
<Route path="/ne" element={<NePage />} />
<Route path="/collect" element={<CollectPage />} />
<Route path="/webcrt" element={<WebcrtPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>

View file

@ -0,0 +1,142 @@
import { useEffect, useRef } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
type Props = {
wsUrl: string;
onStatus?: (state: string, message?: string) => void;
onReady?: () => void;
};
export function WebTerminal({ wsUrl, onStatus, onReady }: Props) {
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const onStatusRef = useRef(onStatus);
const onReadyRef = useRef(onReady);
useEffect(() => {
onStatusRef.current = onStatus;
onReadyRef.current = onReady;
}, [onStatus, onReady]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Consolas, "Courier New", monospace',
theme: {
background: "#0f172a",
foreground: "#e2e8f0",
cursor: "#93c5fd",
},
convertEol: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(host);
fit.fit();
termRef.current = term;
fitRef.current = fit;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
onStatusRef.current?.("connecting");
const sendJson = (payload: Record<string, unknown>) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
};
const sendResize = () => {
fit.fit();
sendJson({ type: "resize", cols: term.cols, rows: term.rows });
};
ws.onopen = () => {
onStatusRef.current?.("open");
sendResize();
onReadyRef.current?.();
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data || "{}")) as {
type?: string;
data?: string;
state?: string;
message?: string;
};
if (msg.type === "stdout" && typeof msg.data === "string") {
term.write(msg.data);
return;
}
if (msg.type === "status") {
onStatusRef.current?.(String(msg.state || ""), msg.message);
if (msg.state === "closed" || msg.state === "error") {
const detail = msg.message ? `: ${msg.message}` : "";
term.writeln(`\r\n\x1b[33m[session ${msg.state}${detail}]\x1b[0m`);
}
return;
}
if (msg.type === "pong") return;
} catch {
term.write(String(ev.data || ""));
}
};
ws.onerror = () => {
onStatusRef.current?.("error", "websocket_error");
};
ws.onclose = () => {
onStatusRef.current?.("closed", "websocket_closed");
};
const dataDisposable = term.onData((data) => {
sendJson({ type: "stdin", data });
});
const pingTimer = window.setInterval(() => {
sendJson({ type: "ping" });
}, 25000);
const onWinResize = () => sendResize();
window.addEventListener("resize", onWinResize);
const ro =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => {
sendResize();
})
: null;
ro?.observe(host);
return () => {
window.clearInterval(pingTimer);
window.removeEventListener("resize", onWinResize);
ro?.disconnect();
dataDisposable.dispose();
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "close" }));
}
ws.close();
} catch {
/* ignore */
}
wsRef.current = null;
term.dispose();
termRef.current = null;
fitRef.current = null;
};
}, [wsUrl]);
return <div className="webcrt-term" ref={hostRef} />;
}

View file

@ -2,7 +2,7 @@
* Single registry for workbench modules. Add new modules here only.
*/
export type ModuleIconTone = "blue" | "green" | "amber";
export type ModuleIconTone = "blue" | "green" | "amber" | "slate";
export type WorkbenchSection = "monitoring" | "operations";
export type ModuleDefinition = {
@ -43,6 +43,15 @@ export const MODULES: readonly ModuleDefinition[] = [
iconTone: "amber",
titleKey: "layout.titleCollect",
},
{
moduleId: "webcrt",
path: "/webcrt",
section: "operations",
labelKey: "workbench.cards.webcrt",
descKey: "workbench.cards.webcrtDesc",
iconTone: "slate",
titleKey: "layout.titleWebcrt",
},
] as const;
export function getModuleById(moduleId: string): ModuleDefinition | undefined {

View file

@ -26,6 +26,8 @@ const en = {
managedNeDesc: "Multi-vendor SSH/Telnet NEs & connectivity test",
collect: "Batch Collect",
collectDesc: "Run custom CLI commands on connected NEs and save logs",
webcrt: "WebCRT",
webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)",
},
},
layout: {
@ -33,6 +35,7 @@ const en = {
titleUme: "UME Sync",
titleManagedNe: "NE Management",
titleCollect: "Batch Collect",
titleWebcrt: "WebCRT",
navUme: "UME",
netxApi: "netx api",
oclawBridge: "oclaw WSS",
@ -135,6 +138,7 @@ const en = {
importing: "Importing…",
edit: "Edit",
delete: "Delete",
openTerminal: "Terminal",
confirmDelete: "Delete this network element?",
batchDelete: "Batch delete",
batchDeleting: "Deleting…",
@ -535,6 +539,42 @@ const en = {
noHostName: "No host_name (sync inventory first)",
},
},
webcrt: {
title: "WebCRT",
sessionManager: "Sessions",
connect: "Connect",
hint: "Pick a connected NE and open an interactive terminal in the browser. Credentials stay on the server.",
hintCrt: "Select an NE on the left, then double-click or press ⚡. Includes managed and UME inventory; search + paging for large fleets.",
filterKeyword: "Filter",
filterKeywordPh: "Filter by name / IP / tag",
empty: "No matching NEs. Switch source or refine the search.",
open: "Open terminal",
opening: "Connecting…",
opened: "Opened {{name}}",
disconnect: "Disconnect",
closed: "Session closed",
termPlaceholder: "No terminal session open",
sessionMeta: "{{name}} · {{ip}}",
source: {
all: "All",
managed: "Managed",
ume: "UME",
},
status: {
idle: "Idle",
connecting: "Connecting",
connected: "Connected",
error: "Error",
closed: "Closed",
},
err: {
sessionLimit: "Session limit reached; close other terminals first",
neNotFound: "Network element not found",
credsIncomplete: "Incomplete username/password on this NE",
cliProfile: "UME NEs require a CLI connect profile (default account / hop)",
connectFailed: "Device login failed: {{detail}}",
},
},
};
export default en;

View file

@ -26,6 +26,8 @@ const zh = {
managedNeDesc: "多厂商 SSH/Telnet 网元与连通性测试",
collect: "批量采集",
collectDesc: "对已连通网元执行定制 CLI 命令并保存日志",
webcrt: "WebCRT",
webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)",
},
},
layout: {
@ -33,6 +35,7 @@ const zh = {
titleUme: "UME同步",
titleManagedNe: "网元管理",
titleCollect: "批量采集",
titleWebcrt: "WebCRT",
navUme: "UME 对接",
netxApi: "netx api",
oclawBridge: "oclaw WSS",
@ -134,6 +137,7 @@ const zh = {
importing: "导入中…",
edit: "编辑",
delete: "删除",
openTerminal: "终端",
confirmDelete: "确定删除该网元?",
batchDelete: "批量删除",
batchDeleting: "删除中…",
@ -533,6 +537,42 @@ const zh = {
noHostName: "无 host_name(需先同步网元)",
},
},
webcrt: {
title: "WebCRT",
sessionManager: "会话管理",
connect: "连接",
hint: "选择已连通网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。",
hintCrt: "在左侧选择网元后双击或点 ⚡ 连接;支持托管网元与 UME 网元,可用搜索与分页浏览上万台设备。",
filterKeyword: "筛选",
filterKeywordPh: "按名称 / IP / 标签筛选",
empty: "没有匹配的网元。可切换来源或调整搜索关键字。",
open: "打开终端",
opening: "连接中…",
opened: "已打开 {{name}}",
disconnect: "断开",
closed: "会话已断开",
termPlaceholder: "尚未打开终端会话",
sessionMeta: "{{name}} · {{ip}}",
source: {
all: "全部",
managed: "托管",
ume: "UME",
},
status: {
idle: "空闲",
connecting: "连接中",
connected: "已连接",
error: "错误",
closed: "已关闭",
},
err: {
sessionLimit: "并发会话已达上限,请先关闭其他终端",
neNotFound: "网元不存在",
credsIncomplete: "网元账号或密码不完整",
cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)",
connectFailed: "登录设备失败:{{detail}}",
},
},
};
export default zh;

View file

@ -168,6 +168,11 @@ body {
color: #f57f17;
}
.wb-card__icon--slate {
background: #eceff1;
color: #455a64;
}
.wb-card__text {
display: flex;
flex-direction: column;
@ -1396,6 +1401,356 @@ pre {
border-radius: 6px;
}
.webcrt-page {
min-height: calc(100vh - 120px);
}
.webcrt-session-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
font-size: 13px;
color: #334155;
}
.webcrt-term-panel {
padding: 0;
overflow: hidden;
min-height: 420px;
flex: 1;
}
.webcrt-term {
width: 100%;
height: min(70vh, 720px);
min-height: 360px;
background: #0f172a;
padding: 8px;
box-sizing: border-box;
}
.webcrt-term .xterm {
height: 100%;
}
.webcrt-term .xterm-viewport {
overflow-y: auto !important;
}
.webcrt-shell {
display: grid;
grid-template-columns: 280px 1fr;
height: calc(100vh - 56px);
min-height: 480px;
margin: -20px -28px -32px;
background: #c0c0c0;
border: 1px solid #808080;
overflow: hidden;
}
.webcrt-sidebar {
display: flex;
flex-direction: column;
background: #f0f0f0;
border-right: 1px solid #808080;
min-width: 0;
}
.webcrt-sidebar__title {
padding: 6px 10px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
background: linear-gradient(#f8f8f8, #e4e4e4);
border-bottom: 1px solid #b0b0b0;
}
.webcrt-sidebar__toolbar {
display: flex;
gap: 4px;
padding: 4px 6px;
border-bottom: 1px solid #d0d0d0;
background: #ececec;
}
.webcrt-icon-btn {
width: 28px;
height: 24px;
padding: 0;
border: 1px solid #a0a0a0;
background: #f7f7f7;
border-radius: 2px;
cursor: pointer;
line-height: 1;
}
.webcrt-icon-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.webcrt-sidebar__search {
padding: 6px;
border-bottom: 1px solid #d0d0d0;
}
.webcrt-sidebar__search input {
width: 100%;
box-sizing: border-box;
height: 28px;
padding: 0 8px;
border: 1px solid #a8a8a8;
border-radius: 2px;
font-size: 12px;
}
.webcrt-source-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
border-bottom: 1px solid #c8c8c8;
}
.webcrt-source-tabs__btn {
height: 26px;
border: 0;
border-right: 1px solid #c8c8c8;
background: #e8e8e8;
font-size: 12px;
cursor: pointer;
}
.webcrt-source-tabs__btn:last-child {
border-right: 0;
}
.webcrt-source-tabs__btn.is-active {
background: #fff;
font-weight: 600;
}
.webcrt-tree {
flex: 1;
overflow: auto;
background: #fff;
min-height: 0;
}
.webcrt-tree__folder {
padding: 6px 8px;
font-size: 12px;
font-weight: 600;
color: #334155;
}
.webcrt-tree__empty {
padding: 10px 12px;
font-size: 12px;
color: #64748b;
}
.webcrt-tree__list {
list-style: none;
margin: 0;
padding: 0 0 8px;
}
.webcrt-tree__item {
width: 100%;
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px 4px 18px;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
font-size: 12px;
}
.webcrt-tree__item:hover {
background: #f1f5f9;
}
.webcrt-tree__item.is-selected,
.webcrt-tree__item.is-active {
background: #cde8ff;
}
.webcrt-tree__icon {
color: #2563eb;
line-height: 1.2;
}
.webcrt-tree__label {
display: flex;
flex-direction: column;
min-width: 0;
}
.webcrt-tree__name {
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.webcrt-tree__meta {
color: #64748b;
font-size: 11px;
}
.webcrt-sidebar__pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px;
border-top: 1px solid #c8c8c8;
background: #ececec;
font-size: 11px;
color: #475569;
}
.webcrt-sidebar__pager-btns {
display: flex;
gap: 2px;
}
.webcrt-sidebar__pager-btns button {
width: 22px;
height: 20px;
border: 1px solid #a0a0a0;
background: #f7f7f7;
cursor: pointer;
}
.webcrt-sidebar__pager-btns button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.webcrt-sidebar__footer {
display: flex;
border-top: 1px solid #a8a8a8;
background: #ddd;
}
.webcrt-sidebar__footer button {
flex: 1;
height: 28px;
border: 0;
border-right: 1px solid #b8b8b8;
background: transparent;
font-size: 12px;
}
.webcrt-sidebar__footer button.is-active {
background: #fff;
font-weight: 600;
}
.webcrt-main {
display: flex;
flex-direction: column;
min-width: 0;
background: #808080;
}
.webcrt-tabs {
display: flex;
gap: 1px;
overflow-x: auto;
background: #6e6e6e;
border-bottom: 1px solid #505050;
min-height: 28px;
}
.webcrt-tabs__item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: #cfcfcf;
color: #1e293b;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.webcrt-tabs__item.is-active {
background: #f3f3f3;
font-weight: 600;
}
.webcrt-tabs__close {
border: 0;
background: transparent;
cursor: pointer;
font-size: 14px;
line-height: 1;
color: #475569;
}
.webcrt-main__body {
flex: 1;
min-height: 0;
background: #0f172a;
}
.webcrt-main__pane {
height: 100%;
}
.webcrt-main__pane .webcrt-term {
height: 100%;
min-height: 0;
}
.webcrt-main__empty,
.webcrt-main__placeholder {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #e2e8f0;
background: #6b7280;
text-align: center;
padding: 24px;
}
.webcrt-main__placeholder--error {
color: #fecaca;
}
.webcrt-statusline {
padding: 3px 8px;
background: #ececec;
border-top: 1px solid #a8a8a8;
font-size: 11px;
color: #334155;
}
@media (max-width: 900px) {
.webcrt-shell {
grid-template-columns: 1fr;
height: auto;
min-height: calc(100vh - 64px);
}
.webcrt-sidebar {
max-height: 42vh;
}
.webcrt-main {
min-height: 50vh;
}
}
@media (max-width: 1200px) {
.cards {
grid-template-columns: 1fr;

View file

@ -26,6 +26,7 @@ import { useToast } from "../hooks/useToast";
import type { ManagedNeItem } from "../types";
import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
import { openOrFocusModule } from "../utils/moduleWindows";
import {
defaultHopTemplate,
isAutoHopTemplate,
@ -772,6 +773,19 @@ export function NePage() {
: t("common.empty")}
</td>
<td className="table-actions">
<button
type="button"
className="link-btn"
onClick={() =>
openOrFocusModule({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(row.id)}`,
})
}
title={t("managedNe.openTerminal")}
>
{t("managedNe.openTerminal")}
</button>
<button
type="button"
className="link-btn"

View file

@ -0,0 +1,379 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { WebTerminal } from "../components/WebTerminal";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import {
closeWebcrtSession,
createWebcrtSession,
fetchCliTargets,
fetchManagedNeById,
webcrtWsUrl,
} from "../services/api";
import { pageCount } from "../utils/display";
import type { CliTargetItem } from "../types";
const PAGE_SIZE = 50;
type TermTab = {
key: string;
sessionId: string;
wsUrl: string;
target: CliTargetItem;
status: "connecting" | "connected" | "error" | "closed";
};
function targetKey(t: Pick<CliTargetItem, "source" | "id">): string {
return `${t.source}:${t.id}`;
}
function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string, string | number>) => string): string {
const raw = String(err);
if (raw.includes("webcrt_session_limit")) return t("webcrt.err.sessionLimit");
if (raw.includes("managed_ne_not_found") || raw.includes("ume_ne_not_found")) return t("webcrt.err.neNotFound");
if (raw.includes("credentials_incomplete") || raw.includes("cli_username_required")) return t("webcrt.err.credsIncomplete");
if (raw.includes("cli_connect_profile_not_configured")) return t("webcrt.err.cliProfile");
if (raw.includes("connect_failed")) return t("webcrt.err.connectFailed", { detail: raw.replace(/^.*connect_failed:/, "") });
return raw;
}
export function WebcrtPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const presetNeId = String(searchParams.get("ne_id") || "").trim();
const presetSource = String(searchParams.get("source") || "managed").trim().toLowerCase();
const [source, setSource] = useState<"all" | "managed" | "ume">("all");
const [keywordInput, setKeywordInput] = useState("");
const [keyword, setKeyword] = useState("");
const [page, setPage] = useState(1);
const [selectedKey, setSelectedKey] = useState("");
const [tabs, setTabs] = useState<TermTab[]>([]);
const [activeTabKey, setActiveTabKey] = useState("");
const connectingKeysRef = useRef<Set<string>>(new Set());
const presetDoneRef = useRef(false);
useEffect(() => {
const timer = window.setTimeout(() => {
setKeyword(keywordInput.trim());
setPage(1);
}, 300);
return () => window.clearTimeout(timer);
}, [keywordInput]);
const targetsQuery = useQuery({
queryKey: ["webcrtTargets", source, keyword, page],
queryFn: () => fetchCliTargets({ source, keyword, page, pageSize: PAGE_SIZE }),
staleTime: 5000,
});
const items = targetsQuery.data?.items ?? [];
const total = targetsQuery.data?.total ?? 0;
const pages = pageCount(total, PAGE_SIZE);
const selected = useMemo(
() => items.find((x) => targetKey(x) === selectedKey) || null,
[items, selectedKey],
);
const updateTab = useCallback((key: string, patch: Partial<TermTab>) => {
setTabs((prev) => prev.map((tab) => (tab.key === key ? { ...tab, ...patch } : tab)));
}, []);
const openTarget = useCallback(
async (target: CliTargetItem) => {
const key = targetKey(target);
const existing = tabs.find((tab) => tab.key === key && tab.status !== "closed" && tab.status !== "error");
if (existing) {
setActiveTabKey(existing.key);
setSelectedKey(key);
return;
}
if (connectingKeysRef.current.has(key)) return;
connectingKeysRef.current.add(key);
setSelectedKey(key);
const pending: TermTab = {
key,
sessionId: "",
wsUrl: "",
target,
status: "connecting",
};
setTabs((prev) => {
const without = prev.filter((x) => x.key !== key);
return [...without, pending];
});
setActiveTabKey(key);
try {
const cols = Math.max(80, Math.floor((window.innerWidth - 360) / 9));
const rows = Math.max(24, Math.floor((window.innerHeight - 180) / 18));
const body =
target.source === "ume"
? { ume_ne_id: target.ume_ne_id || target.id, cols, rows }
: { ne_id: target.id, cols, rows };
const sess = await createWebcrtSession(body);
const wsUrl = webcrtWsUrl(sess.session_id);
updateTab(key, { sessionId: sess.session_id, wsUrl, status: "connecting" });
showOk(t("webcrt.opened", { name: target.name || target.ip_address }));
} catch (err) {
updateTab(key, { status: "error" });
showError(webcrtErrorMessage(err, t));
} finally {
connectingKeysRef.current.delete(key);
}
},
[tabs, showOk, showError, t, updateTab],
);
const closeTab = useCallback(
async (key: string) => {
const tab = tabs.find((x) => x.key === key);
if (tab?.sessionId) {
try {
await closeWebcrtSession(tab.sessionId);
} catch {
/* ignore */
}
}
setTabs((prev) => {
const next = prev.filter((x) => x.key !== key);
if (activeTabKey === key) {
setActiveTabKey(next.length ? next[next.length - 1].key : "");
}
return next;
});
},
[tabs, activeTabKey],
);
// One-shot open from /webcrt?ne_id=...
useEffect(() => {
if (!presetNeId || presetDoneRef.current) return;
let cancelled = false;
(async () => {
presetDoneRef.current = true;
try {
if (presetSource === "ume") {
await openTarget({
source: "ume",
id: presetNeId,
ume_ne_id: presetNeId,
name: presetNeId,
ip_address: "",
connect_status: "unknown",
});
} else {
const hit = items.find((x) => x.source === "managed" && x.id === presetNeId);
if (hit) {
await openTarget(hit);
} else {
const row = await fetchManagedNeById(presetNeId);
if (cancelled) return;
await openTarget({
source: "managed",
id: row.id,
name: row.name || row.ip_address,
ip_address: row.ip_address,
vendor: row.vendor,
device_type: row.device_type,
connect_status: row.connect_status,
cli_profile_ready: true,
});
}
}
} catch (err) {
if (!cancelled) showError(webcrtErrorMessage(err, t));
} finally {
setSearchParams({}, { replace: true });
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [presetNeId]);
const activeTab = tabs.find((x) => x.key === activeTabKey) || null;
const perPage = (n: number) => t("common.perPage", { n });
return (
<div className="webcrt-shell">
<aside className="webcrt-sidebar">
<div className="webcrt-sidebar__title">{t("webcrt.sessionManager")}</div>
<div className="webcrt-sidebar__toolbar">
<button
type="button"
className="webcrt-icon-btn"
title={t("webcrt.connect")}
disabled={!selected}
onClick={() => selected && void openTarget(selected)}
>
⚡
</button>
<button
type="button"
className="webcrt-icon-btn"
title={t("common.refresh")}
onClick={() => void targetsQuery.refetch()}
>
↻
</button>
</div>
<div className="webcrt-sidebar__search">
<input
type="search"
value={keywordInput}
placeholder={t("webcrt.filterKeywordPh")}
onChange={(e) => setKeywordInput(e.target.value)}
/>
</div>
<div className="webcrt-source-tabs">
{(["all", "managed", "ume"] as const).map((s) => (
<button
key={s}
type="button"
className={`webcrt-source-tabs__btn${source === s ? " is-active" : ""}`}
onClick={() => {
setSource(s);
setPage(1);
}}
>
{t(`webcrt.source.${s}`)}
</button>
))}
</div>
<div className="webcrt-tree">
<div className="webcrt-tree__folder">Sessions</div>
{targetsQuery.isLoading ? <div className="webcrt-tree__empty">{t("common.refreshing")}</div> : null}
{!targetsQuery.isLoading && items.length === 0 ? (
<div className="webcrt-tree__empty">{t("webcrt.empty")}</div>
) : null}
<ul className="webcrt-tree__list">
{items.map((row) => {
const key = targetKey(row);
const tab = tabs.find((x) => x.key === key);
const isConnecting = tab?.status === "connecting" || connectingKeysRef.current.has(key);
return (
<li key={key}>
<button
type="button"
className={`webcrt-tree__item${selectedKey === key ? " is-selected" : ""}${activeTabKey === key ? " is-active" : ""}`}
onClick={() => setSelectedKey(key)}
onDoubleClick={() => void openTarget(row)}
title={`${row.name}\n${row.ip_address}\n${row.source}`}
>
<span className="webcrt-tree__icon" aria-hidden>
▣
</span>
<span className="webcrt-tree__label">
<span className="webcrt-tree__name">{row.ip_address || row.name}</span>
<span className="webcrt-tree__meta">
{row.source === "ume" ? "UME" : "NE"}
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
</span>
</span>
</button>
</li>
);
})}
</ul>
</div>
<div className="webcrt-sidebar__pager">
<span>
{t("common.pagerMeta", { total, page, pages })}
</span>
<div className="webcrt-sidebar__pager-btns">
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
‹
</button>
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
›
</button>
</div>
<span>{perPage(PAGE_SIZE)}</span>
</div>
<div className="webcrt-sidebar__footer">
<button type="button" className="is-active">
{t("webcrt.sessionManager")}
</button>
</div>
</aside>
<main className="webcrt-main">
{tabs.length > 0 ? (
<>
<div className="webcrt-tabs">
{tabs.map((tab) => (
<div
key={tab.key}
className={`webcrt-tabs__item${activeTabKey === tab.key ? " is-active" : ""}`}
onClick={() => setActiveTabKey(tab.key)}
>
<span>
{tab.target.ip_address || tab.target.name}
{tab.status === "connecting" ? ` (${t("webcrt.status.connecting")})` : ""}
</span>
<button
type="button"
className="webcrt-tabs__close"
aria-label={t("webcrt.disconnect")}
onClick={(e) => {
e.stopPropagation();
void closeTab(tab.key);
}}
>
×
</button>
</div>
))}
</div>
<div className="webcrt-main__body">
{tabs.map((tab) => (
<div
key={tab.key}
className="webcrt-main__pane"
hidden={activeTabKey !== tab.key}
>
{tab.status === "connecting" && !tab.wsUrl ? (
<div className="webcrt-main__placeholder">{t("webcrt.status.connecting")}…</div>
) : null}
{tab.status === "error" && !tab.wsUrl ? (
<div className="webcrt-main__placeholder webcrt-main__placeholder--error">
{t("webcrt.status.error")}
</div>
) : null}
{tab.wsUrl ? (
<WebTerminal
wsUrl={tab.wsUrl}
onStatus={(state) => {
if (state === "open" || state === "connected") updateTab(tab.key, { status: "connected" });
else if (state === "error") updateTab(tab.key, { status: "error" });
else if (state === "closed") updateTab(tab.key, { status: "closed" });
}}
/>
) : null}
</div>
))}
</div>
</>
) : (
<div className="webcrt-main__empty">
<p>{t("webcrt.termPlaceholder")}</p>
<p className="panel__hint">{t("webcrt.hintCrt")}</p>
</div>
)}
{activeTab ? (
<div className="webcrt-statusline">
{activeTab.target.name} · {activeTab.target.ip_address} · {activeTab.target.source} ·{" "}
{t(`webcrt.status.${activeTab.status}`)}
</div>
) : null}
</main>
</div>
);
}

View file

@ -324,6 +324,37 @@ export const collectionRunDownloadUrl = (runId: string) => `/v1/ne-collections/r
export const collectionJobDownloadUrl = (jobId: string) => `/v1/ne-collections/${jobId}/download`;
export const fetchManagedNeById = (neId: string) =>
apiGet<ManagedNeItem>(`/v1/managed-ne/${encodeURIComponent(neId)}`);
export type WebcrtSessionCreateResult = {
session_id: string;
ne_id: string;
ne_name: string;
ne_ip: string;
protocol: string;
cols: number;
rows: number;
ws_path: string;
};
export const createWebcrtSession = (body: {
ne_id?: string;
ume_ne_id?: string;
cols?: number;
rows?: number;
}) => apiPost<WebcrtSessionCreateResult>("/v1/webcrt/sessions", body);
export const closeWebcrtSession = (sessionId: string) =>
apiDelete<{ ok: boolean; session_id: string; closed: boolean }>(
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
);
export const webcrtWsUrl = (sessionId: string): string => {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;
};
export const fetchCliMeta = () => apiGet<CliMeta>("/v1/cli/meta");
export const fetchCliProfiles = () =>

View file

@ -52,7 +52,11 @@ export function registerModuleWindow(moduleId: string): () => void {
export function openOrFocusModule({ moduleId, path }: ModuleWindowSpec): void {
const mod = getModuleById(moduleId);
const targetPath = mod?.path ?? path;
const base = mod?.path ?? "/";
// Prefer caller path so query strings like /webcrt?ne_id=... are preserved.
const targetPath = path && (path === base || path.startsWith(`${base}?`) || path.startsWith(`${base}/`))
? path
: base;
const requestId = createFocusRequestId(`mod-${moduleId}`);
const name = moduleWindowName(moduleId);

View file

@ -10,6 +10,7 @@ export default defineConfig({
"/v1": {
target: "http://127.0.0.1:8890",
changeOrigin: true,
ws: true,
},
},
},