mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
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:
parent
88479245bb
commit
873f2bfe34
21 changed files with 1868 additions and 45 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,3 +9,4 @@ dist/
|
|||
scripts/.run/
|
||||
ume/
|
||||
data/ne_collections/
|
||||
data/webcrt/
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
170
netx_api/webcrt_router.py
Normal 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
378
netx_api/webcrt_service.py
Normal 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
185
tests/test_webcrt.py
Normal 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()
|
||||
10
web/WEB.md
10
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()`
|
||||
|
|
|
|||
17
web/package-lock.json
generated
17
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
142
web/src/components/WebTerminal.tsx
Normal file
142
web/src/components/WebTerminal.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
379
web/src/pages/WebcrtPage.tsx
Normal file
379
web/src/pages/WebcrtPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = () =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export default defineConfig({
|
|||
"/v1": {
|
||||
target: "http://127.0.0.1:8890",
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue