diff --git a/.gitignore b/.gitignore index c2f6b4d..c9ad659 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist/ scripts/.run/ ume/ data/ne_collections/ +data/webcrt/ diff --git a/netx_api/cli_service.py b/netx_api/cli_service.py index e153cb0..9bfeec3 100644 --- a/netx_api/cli_service.py +++ b/netx_api/cli_service.py @@ -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} diff --git a/netx_api/config.py b/netx_api/config.py index 288e499..948f0c5 100644 --- a/netx_api/config.py +++ b/netx_api/config.py @@ -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() diff --git a/netx_api/main.py b/netx_api/main.py index 72da24e..d528c7f 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -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(), diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py new file mode 100644 index 0000000..840a647 --- /dev/null +++ b/netx_api/webcrt_router.py @@ -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), + ) diff --git a/netx_api/webcrt_service.py b/netx_api/webcrt_service.py new file mode 100644 index 0000000..500ea5b --- /dev/null +++ b/netx_api/webcrt_service.py @@ -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, + } diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py new file mode 100644 index 0000000..8a120ee --- /dev/null +++ b/tests/test_webcrt.py @@ -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() diff --git a/web/WEB.md b/web/WEB.md index d0eb4f7..a3ecbf3 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -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()` diff --git a/web/package-lock.json b/web/package-lock.json index 0ce2772..c4267f2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 8e3ea9a..46be69a 100644 --- a/web/package.json +++ b/web/package.json @@ -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" diff --git a/web/src/App.tsx b/web/src/App.tsx index 48f764a..99fba39 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx new file mode 100644 index 0000000..4061f8c --- /dev/null +++ b/web/src/components/WebTerminal.tsx @@ -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(null); + const termRef = useRef(null); + const fitRef = useRef(null); + const wsRef = useRef(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) => { + 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
; +} diff --git a/web/src/config/modules.ts b/web/src/config/modules.ts index 2e8ff16..d127bcb 100644 --- a/web/src/config/modules.ts +++ b/web/src/config/modules.ts @@ -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 { diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 44e33be..5a74de5 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b4212df..22e36e3 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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; diff --git a/web/src/index.css b/web/src/index.css index 0e9c0e5..91e0109 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx index 428c149..84ba8b4 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/pages/NePage.tsx @@ -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")} + + +
+
+ setKeywordInput(e.target.value)} + /> +
+
+ {(["all", "managed", "ume"] as const).map((s) => ( + + ))} +
+
+
Sessions
+ {targetsQuery.isLoading ?
{t("common.refreshing")}
: null} + {!targetsQuery.isLoading && items.length === 0 ? ( +
{t("webcrt.empty")}
+ ) : null} +
    + {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 ( +
  • + +
  • + ); + })} +
+
+
+ + {t("common.pagerMeta", { total, page, pages })} + +
+ + +
+ {perPage(PAGE_SIZE)} +
+
+ +
+ + +
+ {tabs.length > 0 ? ( + <> +
+ {tabs.map((tab) => ( +
setActiveTabKey(tab.key)} + > + + {tab.target.ip_address || tab.target.name} + {tab.status === "connecting" ? ` (${t("webcrt.status.connecting")})` : ""} + + +
+ ))} +
+
+ {tabs.map((tab) => ( + + ))} +
+ + ) : ( +
+

{t("webcrt.termPlaceholder")}

+

{t("webcrt.hintCrt")}

+
+ )} + {activeTab ? ( +
+ {activeTab.target.name} · {activeTab.target.ip_address} · {activeTab.target.source} ·{" "} + {t(`webcrt.status.${activeTab.status}`)} +
+ ) : null} +
+ + ); +} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index e2bfbdf..a2cdeaf 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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(`/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("/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("/v1/cli/meta"); export const fetchCliProfiles = () => diff --git a/web/src/utils/moduleWindows.ts b/web/src/utils/moduleWindows.ts index 717770e..5da2806 100644 --- a/web/src/utils/moduleWindows.ts +++ b/web/src/utils/moduleWindows.ts @@ -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); diff --git a/web/vite.config.ts b/web/vite.config.ts index e44a9bd..7e7d7f6 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "/v1": { target: "http://127.0.0.1:8890", changeOrigin: true, + ws: true, }, }, },