mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 16:30:47 +08:00
Harden WebCRT interactive sessions for more CRT-like device login.
Improve async connect, binary stdout coalescing, paste throttling with progress, SSH keepalive, login prompt settle, and optional SFTP while keeping core terminal UX focused. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cb865254e4
commit
accd28c9a1
13 changed files with 1884 additions and 224 deletions
|
|
@ -88,6 +88,17 @@ class Settings(BaseSettings):
|
||||||
webcrt_connect_timeout_sec: int = 90
|
webcrt_connect_timeout_sec: int = 90
|
||||||
webcrt_attach_timeout_sec: int = 60
|
webcrt_attach_timeout_sec: int = 60
|
||||||
webcrt_data_dir: str = "data/webcrt"
|
webcrt_data_dir: str = "data/webcrt"
|
||||||
|
# SSH transport keepalive interval (seconds); 0 disables.
|
||||||
|
webcrt_keepalive_sec: int = 30
|
||||||
|
# Device anti-idle CLI nudge (0 = off). Keep off: NEs close idle VTY themselves.
|
||||||
|
webcrt_anti_idle_sec: int = 0
|
||||||
|
webcrt_anti_idle_payload: str = " "
|
||||||
|
# Cap stdout queue depth (drop oldest when full) to protect memory.
|
||||||
|
webcrt_out_queue_max: int = 2000
|
||||||
|
# Persist per-session transcripts under webcrt_data_dir/sessions/.
|
||||||
|
webcrt_session_log_enabled: bool = True
|
||||||
|
# Reader: short blocking wait instead of fixed 40ms spin (seconds).
|
||||||
|
webcrt_reader_poll_sec: float = 0.01
|
||||||
# Local app login / audit (lab defaults; override in production)
|
# Local app login / audit (lab defaults; override in production)
|
||||||
auth_enabled: bool = True
|
auth_enabled: bool = True
|
||||||
# Stable default so JWT survives restarts without .env. Override in production.
|
# Stable default so JWT survives restarts without .env. Override in production.
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,7 @@ def _netmiko_over_ssh_client(
|
||||||
session_timeout: int | None,
|
session_timeout: int | None,
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
|
keepalive: int | None = None,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
"""Netmiko session over an already-authenticated SSH client (bastion protocol proxy)."""
|
"""Netmiko session over an already-authenticated SSH client (bastion protocol proxy)."""
|
||||||
base_cls = _netmiko_driver_class(device_type)
|
base_cls = _netmiko_driver_class(device_type)
|
||||||
|
|
@ -340,6 +341,7 @@ def _netmiko_over_ssh_client(
|
||||||
enable_secret=enable_secret,
|
enable_secret=enable_secret,
|
||||||
session_timeout=session_timeout,
|
session_timeout=session_timeout,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
|
keepalive=keepalive,
|
||||||
)
|
)
|
||||||
return _PreauthSession(**dev)
|
return _PreauthSession(**dev)
|
||||||
|
|
||||||
|
|
@ -354,6 +356,7 @@ def _base_connect_kwargs(
|
||||||
enable_secret: str,
|
enable_secret: str,
|
||||||
session_timeout: int | None = None,
|
session_timeout: int | None = None,
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
|
keepalive: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
timeout = int(settings.ne_connect_timeout_sec or 30)
|
timeout = int(settings.ne_connect_timeout_sec or 30)
|
||||||
dev: dict[str, Any] = {
|
dev: dict[str, Any] = {
|
||||||
|
|
@ -368,6 +371,9 @@ def _base_connect_kwargs(
|
||||||
}
|
}
|
||||||
if session_timeout is not None:
|
if session_timeout is not None:
|
||||||
dev["session_timeout"] = session_timeout
|
dev["session_timeout"] = session_timeout
|
||||||
|
if keepalive is not None and int(keepalive) > 0:
|
||||||
|
# Paramiko/Netmiko SSH transport keepalive (seconds between null packets).
|
||||||
|
dev["keepalive"] = int(keepalive)
|
||||||
secret = str(enable_secret or "").strip()
|
secret = str(enable_secret or "").strip()
|
||||||
if secret:
|
if secret:
|
||||||
dev["secret"] = secret
|
dev["secret"] = secret
|
||||||
|
|
@ -382,6 +388,7 @@ def _connect_direct(
|
||||||
session_timeout: int | None = None,
|
session_timeout: int | None = None,
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
|
keepalive: int | None = None,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
|
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
|
||||||
dev = _base_connect_kwargs(
|
dev = _base_connect_kwargs(
|
||||||
|
|
@ -393,6 +400,7 @@ def _connect_direct(
|
||||||
enable_secret=str(creds.get("enable_secret") or ""),
|
enable_secret=str(creds.get("enable_secret") or ""),
|
||||||
session_timeout=session_timeout,
|
session_timeout=session_timeout,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
|
keepalive=keepalive,
|
||||||
)
|
)
|
||||||
return _build_netmiko_connection(dev, interactive=interactive)
|
return _build_netmiko_connection(dev, interactive=interactive)
|
||||||
|
|
||||||
|
|
@ -602,6 +610,7 @@ def _connect_via_cli_hop(
|
||||||
cols: int | None = None,
|
cols: int | None = None,
|
||||||
rows: int | None = None,
|
rows: int | None = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
|
keepalive: int | None = None,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
"""Login to ZTE/Huawei/Cisco hop NE, run CLI jump command, then target secondary auth."""
|
"""Login to ZTE/Huawei/Cisco hop NE, run CLI jump command, then target secondary auth."""
|
||||||
hop_host = str(creds.get("hop_host") or "").strip()
|
hop_host = str(creds.get("hop_host") or "").strip()
|
||||||
|
|
@ -621,6 +630,7 @@ def _connect_via_cli_hop(
|
||||||
enable_secret="",
|
enable_secret="",
|
||||||
session_timeout=session_timeout or 180,
|
session_timeout=session_timeout or 180,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
|
keepalive=keepalive,
|
||||||
)
|
)
|
||||||
conn = _build_netmiko_connection(hop_dev, interactive=interactive)
|
conn = _build_netmiko_connection(hop_dev, interactive=interactive)
|
||||||
try:
|
try:
|
||||||
|
|
@ -674,6 +684,7 @@ def _connect_via_bastion(
|
||||||
session_timeout: int | None = None,
|
session_timeout: int | None = None,
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
|
keepalive: int | None = None,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
"""SSH to bastion with composite username; bastion proxies to target (protocol proxy)."""
|
"""SSH to bastion with composite username; bastion proxies to target (protocol proxy)."""
|
||||||
hop_host = str(creds.get("hop_host") or "").strip()
|
hop_host = str(creds.get("hop_host") or "").strip()
|
||||||
|
|
@ -707,6 +718,7 @@ def _connect_via_bastion(
|
||||||
session_timeout=session_timeout or 180,
|
session_timeout=session_timeout or 180,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
|
keepalive=keepalive,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
if ssh_client is not None:
|
if ssh_client is not None:
|
||||||
|
|
@ -738,6 +750,7 @@ def _connect_via_linux_hop(
|
||||||
session_timeout: int | None = None,
|
session_timeout: int | None = None,
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
|
keepalive: int | None = None,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
"""SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style)."""
|
"""SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style)."""
|
||||||
hop_host = str(creds.get("hop_host") or "").strip()
|
hop_host = str(creds.get("hop_host") or "").strip()
|
||||||
|
|
@ -791,6 +804,7 @@ def _connect_via_linux_hop(
|
||||||
enable_secret=str(creds.get("enable_secret") or ""),
|
enable_secret=str(creds.get("enable_secret") or ""),
|
||||||
session_timeout=session_timeout,
|
session_timeout=session_timeout,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
|
keepalive=keepalive,
|
||||||
)
|
)
|
||||||
dev["sock"] = channel
|
dev["sock"] = channel
|
||||||
conn = _build_netmiko_connection(dev, interactive=interactive)
|
conn = _build_netmiko_connection(dev, interactive=interactive)
|
||||||
|
|
@ -822,12 +836,16 @@ def open_netmiko_connection(
|
||||||
cols: int | None = None,
|
cols: int | None = None,
|
||||||
rows: int | None = None,
|
rows: int | None = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
|
keepalive: int | None = None,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
"""Open a Netmiko connection to the target NE (direct or via configured hop).
|
"""Open a Netmiko connection to the target NE (direct or via configured hop).
|
||||||
|
|
||||||
``interactive=True`` (WebCRT) skips Netmiko's automatic ``terminal length`` /
|
``interactive=True`` (WebCRT) skips Netmiko's automatic ``terminal length`` /
|
||||||
``terminal width`` (and vendor equivalents). Collection / MCP keep the default.
|
``terminal width`` (and vendor equivalents). Collection / MCP keep the default.
|
||||||
"""
|
"""
|
||||||
|
ka = keepalive
|
||||||
|
if ka is None and interactive:
|
||||||
|
ka = int(getattr(settings, "webcrt_keepalive_sec", 0) or 0) or None
|
||||||
if creds.get("hop_enabled"):
|
if creds.get("hop_enabled"):
|
||||||
vendor = _hop_vendor(creds)
|
vendor = _hop_vendor(creds)
|
||||||
if vendor == "linux":
|
if vendor == "linux":
|
||||||
|
|
@ -836,6 +854,7 @@ def open_netmiko_connection(
|
||||||
session_timeout=session_timeout,
|
session_timeout=session_timeout,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
|
keepalive=ka,
|
||||||
)
|
)
|
||||||
if vendor == "bastion":
|
if vendor == "bastion":
|
||||||
return _connect_via_bastion(
|
return _connect_via_bastion(
|
||||||
|
|
@ -843,6 +862,7 @@ def open_netmiko_connection(
|
||||||
session_timeout=session_timeout,
|
session_timeout=session_timeout,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
|
keepalive=ka,
|
||||||
)
|
)
|
||||||
return _connect_via_cli_hop(
|
return _connect_via_cli_hop(
|
||||||
creds,
|
creds,
|
||||||
|
|
@ -851,10 +871,12 @@ def open_netmiko_connection(
|
||||||
cols=cols,
|
cols=cols,
|
||||||
rows=rows,
|
rows=rows,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
|
keepalive=ka,
|
||||||
)
|
)
|
||||||
return _connect_direct(
|
return _connect_direct(
|
||||||
creds,
|
creds,
|
||||||
session_timeout=session_timeout,
|
session_timeout=session_timeout,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
|
keepalive=ka,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -21,6 +21,9 @@ from .webcrt_service import (
|
||||||
get_session,
|
get_session,
|
||||||
list_sessions,
|
list_sessions,
|
||||||
mark_attached,
|
mark_attached,
|
||||||
|
wait_session_ready,
|
||||||
|
_decode_bytes,
|
||||||
|
_normalize_encoding,
|
||||||
)
|
)
|
||||||
|
|
||||||
_log = logging.getLogger("netx.webcrt.router")
|
_log = logging.getLogger("netx.webcrt.router")
|
||||||
|
|
@ -33,6 +36,22 @@ class WebcrtSessionCreate(BaseModel):
|
||||||
ume_ne_id: str | None = Field(default=None)
|
ume_ne_id: str | None = Field(default=None)
|
||||||
cols: int = Field(default=80, ge=20, le=500)
|
cols: int = Field(default=80, ge=20, le=500)
|
||||||
rows: int = Field(default=24, ge=5, le=200)
|
rows: int = Field(default=24, ge=5, le=200)
|
||||||
|
encoding: str = Field(default="utf-8")
|
||||||
|
post_login_commands: list[str] = Field(default_factory=list)
|
||||||
|
# Default async so UI can open WS while connect runs; tests may force sync via service API.
|
||||||
|
async_connect: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class WebcrtSftpListBody(BaseModel):
|
||||||
|
ne_id: str | None = Field(default=None)
|
||||||
|
ume_ne_id: str | None = Field(default=None)
|
||||||
|
path: str = Field(default=".")
|
||||||
|
|
||||||
|
|
||||||
|
class WebcrtSftpDownloadBody(BaseModel):
|
||||||
|
ne_id: str | None = Field(default=None)
|
||||||
|
ume_ne_id: str | None = Field(default=None)
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
def _client_label(request: Request | None = None, websocket: WebSocket | None = None) -> str:
|
def _client_label(request: Request | None = None, websocket: WebSocket | None = None) -> str:
|
||||||
|
|
@ -66,6 +85,9 @@ def api_create_session(
|
||||||
cols=body.cols,
|
cols=body.cols,
|
||||||
rows=body.rows,
|
rows=body.rows,
|
||||||
client=_client_label(request=request),
|
client=_client_label(request=request),
|
||||||
|
encoding=body.encoding,
|
||||||
|
post_login_commands=list(body.post_login_commands or [])[:20],
|
||||||
|
async_connect=bool(body.async_connect),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -74,6 +96,61 @@ def api_close_session(session_id: str, request: Request) -> dict[str, Any]:
|
||||||
return close_session(session_id, reason="client_delete", client=_client_label(request=request))
|
return close_session(session_id, reason="client_delete", client=_client_label(request=request))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sftp/list")
|
||||||
|
def api_sftp_list(body: WebcrtSftpListBody, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
from .webcrt_sftp import sftp_list
|
||||||
|
|
||||||
|
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 sftp_list(db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sftp/download")
|
||||||
|
def api_sftp_download(body: WebcrtSftpDownloadBody, db: Session = Depends(get_db)) -> Any:
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from .webcrt_sftp import sftp_download
|
||||||
|
|
||||||
|
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")
|
||||||
|
data, filename = sftp_download(db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path)
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sftp/upload")
|
||||||
|
async def api_sftp_upload(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ne_id: str | None = Form(default=None),
|
||||||
|
ume_ne_id: str | None = Form(default=None),
|
||||||
|
remote_path: str = Form(...),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from .webcrt_sftp import sftp_upload
|
||||||
|
|
||||||
|
mid = str(ne_id or "").strip()
|
||||||
|
uid = str(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")
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > 8 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=413, detail="sftp_file_too_large")
|
||||||
|
return sftp_upload(
|
||||||
|
db,
|
||||||
|
managed_ne_id=mid or None,
|
||||||
|
ume_ne_id=uid or None,
|
||||||
|
remote_path=remote_path,
|
||||||
|
data=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/sessions/{session_id}/ws")
|
@router.websocket("/sessions/{session_id}/ws")
|
||||||
async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
if bool(settings.auth_enabled):
|
if bool(settings.auth_enabled):
|
||||||
|
|
@ -90,8 +167,6 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
await websocket.close(code=4401)
|
await websocket.close(code=4401)
|
||||||
return
|
return
|
||||||
websocket.state.auth_user = resolved[0]
|
|
||||||
websocket.state.auth_via = resolved[1]
|
|
||||||
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
attach_gen = 0
|
attach_gen = 0
|
||||||
|
|
@ -105,12 +180,13 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "status",
|
"type": "status",
|
||||||
"state": "connected",
|
"state": "connecting" if sess.state == "connecting" else "connected",
|
||||||
"session_id": sess.session_id,
|
"session_id": sess.session_id,
|
||||||
"ne_id": sess.ne_id,
|
"ne_id": sess.ne_id,
|
||||||
"ne_name": sess.ne_name,
|
"ne_name": sess.ne_name,
|
||||||
"ne_ip": sess.ne_ip,
|
"ne_ip": sess.ne_ip,
|
||||||
"protocol": sess.protocol,
|
"protocol": sess.protocol,
|
||||||
|
"encoding": sess.encoding,
|
||||||
"cols": sess.cols,
|
"cols": sess.cols,
|
||||||
"rows": sess.rows,
|
"rows": sess.rows,
|
||||||
"device_type": sess.device_type,
|
"device_type": sess.device_type,
|
||||||
|
|
@ -119,30 +195,122 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Wait for async connect without blocking the event loop.
|
||||||
|
if sess.state == "connecting":
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: wait_session_ready(
|
||||||
|
session_id,
|
||||||
|
timeout=max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sess = get_session(session_id) or sess
|
||||||
|
except HTTPException as exc:
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "status", "state": "error", "message": str(exc.detail)}
|
||||||
|
)
|
||||||
|
await websocket.close(code=4502)
|
||||||
|
return
|
||||||
|
|
||||||
|
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,
|
||||||
|
"encoding": sess.encoding,
|
||||||
|
"cols": sess.cols,
|
||||||
|
"rows": sess.rows,
|
||||||
|
"device_type": sess.device_type,
|
||||||
|
"vendor": sess.vendor,
|
||||||
|
"cli_hop": bool(sess.cli_hop_guard),
|
||||||
|
"connect_ms": (
|
||||||
|
int((sess.connect_finished_at - sess.connect_started_at) * 1000)
|
||||||
|
if sess.connect_finished_at
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Replay full login transcript (kept for StrictMode remount / brief reconnect).
|
# Replay full login transcript (kept for StrictMode remount / brief reconnect).
|
||||||
bootstrap = bytes(sess.bootstrap_output or b"")
|
bootstrap = bytes(sess.bootstrap_output or b"")
|
||||||
if bootstrap:
|
if bootstrap:
|
||||||
try:
|
try:
|
||||||
await websocket.send_json(
|
if _normalize_encoding(sess.encoding) != "utf-8":
|
||||||
{"type": "stdout", "data": bootstrap.decode("utf-8", errors="replace")}
|
bootstrap = _decode_bytes(bootstrap, sess.encoding).encode("utf-8", errors="replace")
|
||||||
)
|
await websocket.send_bytes(bootstrap)
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True)
|
try:
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "stdout", "data": _decode_bytes(bytes(sess.bootstrap_output or b""), sess.encoding)}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True)
|
||||||
|
|
||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
|
stdin_buf: list[str] = []
|
||||||
|
stdin_flush_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
async def flush_stdin() -> None:
|
||||||
|
nonlocal stdin_buf
|
||||||
|
if not stdin_buf:
|
||||||
|
return
|
||||||
|
data = "".join(stdin_buf)
|
||||||
|
stdin_buf = []
|
||||||
|
try:
|
||||||
|
await asyncio.get_running_loop().run_in_executor(None, sess.write_stdin, data)
|
||||||
|
except Exception as exc:
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "status", "state": "error", "message": f"write_failed:{exc}"}
|
||||||
|
)
|
||||||
|
stop.set()
|
||||||
|
|
||||||
|
async def schedule_stdin_flush() -> None:
|
||||||
|
await asyncio.sleep(0.008)
|
||||||
|
await flush_stdin()
|
||||||
|
|
||||||
async def pump_stdout() -> None:
|
async def pump_stdout() -> None:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
pending: list[bytes] = []
|
||||||
|
last_flush = loop.time()
|
||||||
|
|
||||||
|
def _to_browser_bytes(raw: bytes) -> bytes:
|
||||||
|
if _normalize_encoding(sess.encoding) == "utf-8":
|
||||||
|
return raw
|
||||||
|
return _decode_bytes(raw, sess.encoding).encode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
async def _flush_pending() -> bool:
|
||||||
|
nonlocal pending, last_flush
|
||||||
|
if not pending:
|
||||||
|
return True
|
||||||
|
blob = _to_browser_bytes(b"".join(pending))
|
||||||
|
pending = []
|
||||||
|
last_flush = loop.time()
|
||||||
|
try:
|
||||||
|
await websocket.send_bytes(blob)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
while not stop.is_set():
|
while not stop.is_set():
|
||||||
chunk = await loop.run_in_executor(
|
chunk = await loop.run_in_executor(
|
||||||
None, lambda: sess.take_stdout(attach_gen, timeout=0.25)
|
None, lambda: sess.take_stdout(attach_gen, timeout=0.05)
|
||||||
)
|
)
|
||||||
if chunk == "stale":
|
if chunk == "stale":
|
||||||
# Newer WS owns the session (StrictMode remount); exit without stealing bytes.
|
|
||||||
break
|
break
|
||||||
if chunk == "empty":
|
if chunk == "empty":
|
||||||
|
if pending and (loop.time() - last_flush) >= 0.016:
|
||||||
|
if not await _flush_pending():
|
||||||
|
stop.set()
|
||||||
|
break
|
||||||
continue
|
continue
|
||||||
if chunk is None:
|
if chunk is None:
|
||||||
|
await _flush_pending()
|
||||||
stop.set()
|
stop.set()
|
||||||
try:
|
try:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
|
|
@ -155,12 +323,11 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
try:
|
pending.append(chunk)
|
||||||
text = chunk.decode("utf-8", errors="replace")
|
if sum(len(p) for p in pending) >= 8192 or (loop.time() - last_flush) >= 0.016:
|
||||||
await websocket.send_json({"type": "stdout", "data": text})
|
if not await _flush_pending():
|
||||||
except Exception:
|
stop.set()
|
||||||
stop.set()
|
break
|
||||||
break
|
|
||||||
|
|
||||||
reader_task = asyncio.create_task(pump_stdout())
|
reader_task = asyncio.create_task(pump_stdout())
|
||||||
if sess.needs_live_prompt:
|
if sess.needs_live_prompt:
|
||||||
|
|
@ -171,35 +338,56 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
_log.debug("webcrt live prompt sync failed session=%s", session_id, exc_info=True)
|
_log.debug("webcrt live prompt sync failed session=%s", session_id, exc_info=True)
|
||||||
try:
|
try:
|
||||||
while not stop.is_set():
|
while not stop.is_set():
|
||||||
raw = await websocket.receive_text()
|
msg_raw = await websocket.receive()
|
||||||
|
if msg_raw.get("type") == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
if "bytes" in msg_raw and msg_raw["bytes"] is not None:
|
||||||
|
# Binary stdin: decode with session encoding.
|
||||||
|
try:
|
||||||
|
text = _decode_bytes(bytes(msg_raw["bytes"]), sess.encoding)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
stdin_buf.append(text)
|
||||||
|
if stdin_flush_task is None or stdin_flush_task.done():
|
||||||
|
stdin_flush_task = asyncio.create_task(schedule_stdin_flush())
|
||||||
|
continue
|
||||||
|
raw = msg_raw.get("text")
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
msg = json.loads(raw)
|
msg = json.loads(raw)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
# Treat plain text as stdin.
|
|
||||||
msg = {"type": "stdin", "data": raw}
|
msg = {"type": "stdin", "data": raw}
|
||||||
mtype = str(msg.get("type") or "").strip().lower()
|
mtype = str(msg.get("type") or "").strip().lower()
|
||||||
if mtype == "stdin":
|
if mtype == "stdin":
|
||||||
data = msg.get("data")
|
data = msg.get("data")
|
||||||
if data is None:
|
if data is None:
|
||||||
continue
|
continue
|
||||||
try:
|
stdin_buf.append(str(data))
|
||||||
await asyncio.get_running_loop().run_in_executor(
|
# Coalesce high-frequency keystrokes briefly.
|
||||||
None, sess.write_stdin, str(data)
|
if len(stdin_buf) >= 8:
|
||||||
)
|
if stdin_flush_task and not stdin_flush_task.done():
|
||||||
except Exception as exc:
|
stdin_flush_task.cancel()
|
||||||
await websocket.send_json(
|
await flush_stdin()
|
||||||
{"type": "status", "state": "error", "message": f"write_failed:{exc}"}
|
elif stdin_flush_task is None or stdin_flush_task.done():
|
||||||
)
|
stdin_flush_task = asyncio.create_task(schedule_stdin_flush())
|
||||||
break
|
|
||||||
elif mtype == "resize":
|
elif mtype == "resize":
|
||||||
cols = int(msg.get("cols") or sess.cols)
|
cols = int(msg.get("cols") or sess.cols)
|
||||||
rows = int(msg.get("rows") or sess.rows)
|
rows = int(msg.get("rows") or sess.rows)
|
||||||
await asyncio.get_running_loop().run_in_executor(None, sess.resize, cols, rows)
|
await asyncio.get_running_loop().run_in_executor(None, sess.resize, cols, rows)
|
||||||
|
elif mtype == "break":
|
||||||
|
try:
|
||||||
|
await asyncio.get_running_loop().run_in_executor(None, sess.send_break)
|
||||||
|
except Exception as exc:
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "status", "state": "error", "message": f"break_failed:{exc}"}
|
||||||
|
)
|
||||||
elif mtype == "ping":
|
elif mtype == "ping":
|
||||||
sess.touch()
|
sess.touch()
|
||||||
await websocket.send_json({"type": "pong"})
|
await websocket.send_json({"type": "pong"})
|
||||||
elif mtype == "close":
|
elif mtype == "close":
|
||||||
stop.set()
|
stop.set()
|
||||||
|
await flush_stdin()
|
||||||
close_session(
|
close_session(
|
||||||
session_id,
|
session_id,
|
||||||
reason="client_close",
|
reason="client_close",
|
||||||
|
|
@ -212,13 +400,18 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
_log.exception("webcrt ws error session=%s", session_id)
|
_log.exception("webcrt ws error session=%s", session_id)
|
||||||
finally:
|
finally:
|
||||||
stop.set()
|
stop.set()
|
||||||
|
if stdin_flush_task and not stdin_flush_task.done():
|
||||||
|
stdin_flush_task.cancel()
|
||||||
|
try:
|
||||||
|
await flush_stdin()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
reader_task.cancel()
|
reader_task.cancel()
|
||||||
try:
|
try:
|
||||||
await reader_task
|
await reader_task
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Keep device session briefly so React remount / blip can re-attach.
|
# Keep device session briefly so React remount / blip can re-attach.
|
||||||
# Only the current attach_gen may detach — older StrictMode sockets must not.
|
|
||||||
if get_session(session_id) is not None:
|
if get_session(session_id) is not None:
|
||||||
detach_session(
|
detach_session(
|
||||||
session_id,
|
session_id,
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ def map_network_cli_enter(data: str, conn: ConnectHandler | None) -> str:
|
||||||
return text.replace("\r\n", ret).replace("\r", ret)
|
return text.replace("\r\n", ret).replace("\r", ret)
|
||||||
|
|
||||||
|
|
||||||
def _drain_channel(conn: ConnectHandler, *, rounds: int = 10, wait: float = 0.12) -> str:
|
def _drain_channel(conn: ConnectHandler, *, rounds: int = 6, wait: float = 0.06) -> str:
|
||||||
"""Read whatever is already sitting on the channel after login."""
|
"""Read whatever is already sitting on the channel after login."""
|
||||||
chunks: list[str] = []
|
chunks: list[str] = []
|
||||||
empty_streak = 0
|
empty_streak = 0
|
||||||
|
|
@ -173,7 +173,11 @@ def normalize_cli_transcript(text: str) -> str:
|
||||||
lines = s.split("\n")
|
lines = s.split("\n")
|
||||||
while lines and not str(lines[-1]).strip():
|
while lines and not str(lines[-1]).strip():
|
||||||
lines.pop()
|
lines.pop()
|
||||||
while len(lines) >= 2 and lines[-1] == lines[-2] and _looks_like_cli_prompt(lines[-1]):
|
# Drop blank lines immediately before a final prompt (banner\n\nR2# -> banner\nR2#).
|
||||||
|
while len(lines) >= 2 and not str(lines[-2]).strip() and _looks_like_cli_prompt(lines[-1]):
|
||||||
|
lines.pop(-2)
|
||||||
|
# Collapse trailing duplicate prompt lines (slow VMs often echo R2# several times).
|
||||||
|
while len(lines) >= 2 and str(lines[-1]).strip() == str(lines[-2]).strip() and _looks_like_cli_prompt(lines[-1]):
|
||||||
lines.pop()
|
lines.pop()
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
@ -187,25 +191,131 @@ def prepare_bootstrap_output(text: str) -> str:
|
||||||
return normalize_cli_transcript(text)
|
return normalize_cli_transcript(text)
|
||||||
|
|
||||||
|
|
||||||
def _prime_interactive_channel(conn: ConnectHandler) -> None:
|
def _drain_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> None:
|
||||||
"""Send one RETURN after Netmiko login so the interactive channel is fully ready."""
|
"""Discard leftover bytes on the live channel (SSH/Telnet) after login priming."""
|
||||||
|
channel = getattr(conn, "remote_conn", None)
|
||||||
|
if channel is None:
|
||||||
|
try:
|
||||||
|
_drain_channel(conn, rounds=max(2, int(duration / 0.05)), wait=0.05)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
end = time.time() + max(0.1, float(duration))
|
||||||
|
while time.time() < end:
|
||||||
|
got = False
|
||||||
|
try:
|
||||||
|
if hasattr(channel, "recv_ready") and hasattr(channel, "recv") and channel.recv_ready():
|
||||||
|
channel.recv(65535)
|
||||||
|
got = True
|
||||||
|
elif hasattr(channel, "read_very_eager"):
|
||||||
|
data = channel.read_very_eager()
|
||||||
|
if data:
|
||||||
|
got = True
|
||||||
|
else:
|
||||||
|
part = conn.read_channel()
|
||||||
|
if part:
|
||||||
|
got = True
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not got:
|
||||||
|
time.sleep(0.04)
|
||||||
|
|
||||||
|
|
||||||
|
def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool = False) -> None:
|
||||||
|
"""Send one RETURN after Netmiko login so the interactive channel is fully ready.
|
||||||
|
|
||||||
|
Skip the sync Enter when the login transcript already ends with a CLI prompt —
|
||||||
|
otherwise slow Cisco VMs accumulate duplicate ``R2#`` lines in the bootstrap.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
_drain_channel(conn, rounds=4, wait=0.05)
|
_drain_raw_channel(conn, duration=0.15)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
if not already_prompted:
|
||||||
conn.write_channel(channel_return(conn))
|
|
||||||
except Exception:
|
|
||||||
try:
|
try:
|
||||||
conn.write_channel("\n")
|
conn.write_channel(channel_return(conn))
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
try:
|
||||||
|
conn.write_channel("\n")
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_drain_channel(conn, rounds=6, wait=0.08)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
_drain_channel(conn, rounds=8, wait=0.1)
|
_drain_raw_channel(conn, duration=0.35)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _is_prompt_only_echo(text: str, prompt_hint: str = "") -> bool:
|
||||||
|
"""True when chunk is only whitespace / CR / a repeated prompt (safe to drop after bootstrap)."""
|
||||||
|
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||||
|
if not s:
|
||||||
|
return True
|
||||||
|
hint = str(prompt_hint or "").strip()
|
||||||
|
if hint and s == hint:
|
||||||
|
return True
|
||||||
|
# Single-line prompt echo only.
|
||||||
|
if "\n" not in s and _looks_like_cli_prompt(s):
|
||||||
|
return True
|
||||||
|
if hint and all(line.strip() in ("", hint) for line in s.split("\n")):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_encoding(name: str) -> str:
|
||||||
|
enc = str(name or "utf-8").strip().lower().replace("_", "-")
|
||||||
|
if enc in ("gbk", "gb2312", "gb18030", "cp936"):
|
||||||
|
return "gbk"
|
||||||
|
return "utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_bytes(data: bytes, encoding: str) -> str:
|
||||||
|
enc = _normalize_encoding(encoding)
|
||||||
|
try:
|
||||||
|
return data.decode(enc, errors="replace")
|
||||||
|
except Exception:
|
||||||
|
return data.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_text(text: str, encoding: str) -> bytes:
|
||||||
|
enc = _normalize_encoding(encoding)
|
||||||
|
try:
|
||||||
|
return text.encode(enc, errors="replace")
|
||||||
|
except Exception:
|
||||||
|
return text.encode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
class _BoundedByteQueue:
|
||||||
|
"""Thread-safe queue that drops oldest chunks when full (backpressure)."""
|
||||||
|
|
||||||
|
def __init__(self, maxsize: int = 2000) -> None:
|
||||||
|
self._q: queue.Queue[bytes | None] = queue.Queue()
|
||||||
|
self._max = max(8, int(maxsize or 2000))
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.dropped = 0
|
||||||
|
|
||||||
|
def put(self, item: bytes | None) -> None:
|
||||||
|
with self._lock:
|
||||||
|
while self._q.qsize() >= self._max:
|
||||||
|
try:
|
||||||
|
self._q.get_nowait()
|
||||||
|
self.dropped += 1
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
self._q.put(item)
|
||||||
|
|
||||||
|
def put_nowait(self, item: bytes | None) -> None:
|
||||||
|
self.put(item)
|
||||||
|
|
||||||
|
def get_nowait(self) -> bytes | None:
|
||||||
|
return self._q.get_nowait()
|
||||||
|
|
||||||
|
def qsize(self) -> int:
|
||||||
|
return self._q.qsize()
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> datetime:
|
def _utc_now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
@ -221,6 +331,12 @@ def webcrt_data_root() -> Path:
|
||||||
return root.resolve()
|
return root.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _session_log_path(session_id: str) -> Path:
|
||||||
|
folder = webcrt_data_root() / "sessions"
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
return folder / f"{session_id}.log"
|
||||||
|
|
||||||
|
|
||||||
def _audit(event: str, **fields: Any) -> None:
|
def _audit(event: str, **fields: Any) -> None:
|
||||||
record = {"ts": _utc_iso(), "event": event, **fields}
|
record = {"ts": _utc_iso(), "event": event, **fields}
|
||||||
try:
|
try:
|
||||||
|
|
@ -244,6 +360,7 @@ class WebcrtSession:
|
||||||
device_type: str = ""
|
device_type: str = ""
|
||||||
vendor: str = ""
|
vendor: str = ""
|
||||||
cli_keymap: bool = True
|
cli_keymap: bool = True
|
||||||
|
encoding: str = "utf-8"
|
||||||
conn: ConnectHandler | None = None
|
conn: ConnectHandler | None = None
|
||||||
created_at: float = field(default_factory=time.time)
|
created_at: float = field(default_factory=time.time)
|
||||||
last_activity: float = field(default_factory=time.time)
|
last_activity: float = field(default_factory=time.time)
|
||||||
|
|
@ -251,24 +368,69 @@ class WebcrtSession:
|
||||||
detach_deadline: float | None = None
|
detach_deadline: float | None = None
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
close_reason: str = ""
|
close_reason: str = ""
|
||||||
|
# connecting | ready | error | closed
|
||||||
|
state: str = "ready"
|
||||||
|
connect_error: str = ""
|
||||||
|
connect_started_at: float = field(default_factory=time.time)
|
||||||
|
connect_finished_at: float | None = None
|
||||||
bootstrap_output: bytes = b""
|
bootstrap_output: bytes = b""
|
||||||
needs_live_prompt: bool = True
|
needs_live_prompt: bool = True
|
||||||
# React StrictMode remounts open a second WS before the first fully tears down.
|
# React StrictMode remounts open a second WS before the first fully tears down.
|
||||||
# Only the newest attach_gen may consume out_queue / mark detach.
|
# Only the newest attach_gen may consume out_queue / mark detach.
|
||||||
attach_gen: int = 0
|
attach_gen: int = 0
|
||||||
out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue)
|
out_queue: _BoundedByteQueue = field(
|
||||||
|
default_factory=lambda: _BoundedByteQueue(int(getattr(settings, "webcrt_out_queue_max", 2000) or 2000))
|
||||||
|
)
|
||||||
# Vendor CLI hop (Huawei/ZTE/Cisco): close when nested target session returns to hop.
|
# Vendor CLI hop (Huawei/ZTE/Cisco): close when nested target session returns to hop.
|
||||||
cli_hop_guard: bool = False
|
cli_hop_guard: bool = False
|
||||||
cli_hop_prompt: str = ""
|
cli_hop_prompt: str = ""
|
||||||
|
post_login_commands: list[str] = field(default_factory=list)
|
||||||
|
bytes_in: int = 0
|
||||||
|
bytes_out: int = 0
|
||||||
_reader: threading.Thread | None = field(default=None, repr=False)
|
_reader: threading.Thread | None = field(default=None, repr=False)
|
||||||
_write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
_write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||||
_stdout_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
_stdout_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||||
_hop_scan_buf: str = field(default="", repr=False)
|
_hop_scan_buf: str = field(default="", repr=False)
|
||||||
_cli_hop_seen_other_prompt: bool = field(default=False, repr=False)
|
_cli_hop_seen_other_prompt: bool = field(default=False, repr=False)
|
||||||
|
_log_fh: Any = field(default=None, repr=False)
|
||||||
|
_ready_event: threading.Event = field(default_factory=threading.Event, repr=False)
|
||||||
|
|
||||||
def touch(self) -> None:
|
def touch(self) -> None:
|
||||||
self.last_activity = time.time()
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
def open_session_log(self) -> None:
|
||||||
|
if not bool(getattr(settings, "webcrt_session_log_enabled", True)):
|
||||||
|
return
|
||||||
|
if self._log_fh is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._log_fh = _session_log_path(self.session_id).open("a", encoding="utf-8", errors="replace")
|
||||||
|
self._log_fh.write(f"# session={self.session_id} ne={self.ne_id} ip={self.ne_ip} ts={_utc_iso()}\n")
|
||||||
|
self._log_fh.flush()
|
||||||
|
except Exception:
|
||||||
|
_log.debug("webcrt session log open failed", exc_info=True)
|
||||||
|
self._log_fh = None
|
||||||
|
|
||||||
|
def append_session_log(self, text: str) -> None:
|
||||||
|
if not text or self._log_fh is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._log_fh.write(text)
|
||||||
|
self._log_fh.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close_session_log(self) -> None:
|
||||||
|
fh = self._log_fh
|
||||||
|
self._log_fh = None
|
||||||
|
if fh is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
fh.write(f"\n# closed reason={self.close_reason} ts={_utc_iso()}\n")
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def take_stdout(self, attach_gen: int, *, timeout: float = 0.25) -> bytes | None | str:
|
def take_stdout(self, attach_gen: int, *, timeout: float = 0.25) -> bytes | None | str:
|
||||||
"""Exclusive stdout take for one WS attach generation.
|
"""Exclusive stdout take for one WS attach generation.
|
||||||
|
|
||||||
|
|
@ -295,7 +457,7 @@ class WebcrtSession:
|
||||||
return chunk # bytes | None
|
return chunk # bytes | None
|
||||||
if time.time() >= deadline:
|
if time.time() >= deadline:
|
||||||
return "empty"
|
return "empty"
|
||||||
time.sleep(0.02)
|
time.sleep(0.005)
|
||||||
|
|
||||||
def write_stdin(self, data: str) -> None:
|
def write_stdin(self, data: str) -> None:
|
||||||
if self.closed or self.conn is None:
|
if self.closed or self.conn is None:
|
||||||
|
|
@ -315,12 +477,10 @@ class WebcrtSession:
|
||||||
return
|
return
|
||||||
with self._write_lock:
|
with self._write_lock:
|
||||||
# Prefer raw channel I/O for interactive typing (char echo / backspace).
|
# Prefer raw channel I/O for interactive typing (char echo / backspace).
|
||||||
# Netmiko write_channel is fine for automation but can feel "half-duplex"
|
|
||||||
# on some Telnet/VRP sessions when used keystroke-by-keystroke.
|
|
||||||
channel = getattr(self.conn, "remote_conn", None)
|
channel = getattr(self.conn, "remote_conn", None)
|
||||||
try:
|
try:
|
||||||
if channel is not None and hasattr(channel, "send") and callable(channel.send):
|
if channel is not None and hasattr(channel, "send") and callable(channel.send):
|
||||||
payload = text.encode(getattr(self.conn, "encoding", None) or "utf-8", errors="replace")
|
payload = _encode_text(text, self.encoding)
|
||||||
# Paramiko may write partially when the window is full.
|
# Paramiko may write partially when the window is full.
|
||||||
view = memoryview(payload)
|
view = memoryview(payload)
|
||||||
while len(view):
|
while len(view):
|
||||||
|
|
@ -329,14 +489,47 @@ class WebcrtSession:
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
continue
|
continue
|
||||||
view = view[n:]
|
view = view[n:]
|
||||||
|
self.bytes_in += len(payload)
|
||||||
elif channel is not None and hasattr(channel, "write") and callable(channel.write):
|
elif channel is not None and hasattr(channel, "write") and callable(channel.write):
|
||||||
encoding = getattr(self.conn, "encoding", None) or "utf-8"
|
payload = _encode_text(text, self.encoding)
|
||||||
channel.write(text.encode(encoding, errors="replace") if isinstance(text, str) else text)
|
channel.write(payload)
|
||||||
|
self.bytes_in += len(payload)
|
||||||
else:
|
else:
|
||||||
self.conn.write_channel(text)
|
self.conn.write_channel(text)
|
||||||
|
self.bytes_in += len(text)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.conn.write_channel(text)
|
self.conn.write_channel(text)
|
||||||
|
self.bytes_in += len(text)
|
||||||
self.touch()
|
self.touch()
|
||||||
|
|
||||||
|
def send_break(self) -> None:
|
||||||
|
"""Send SSH break / Telnet IAC BREAK to interrupt paging or hung commands."""
|
||||||
|
if self.closed or self.conn is None:
|
||||||
|
raise RuntimeError("session_closed")
|
||||||
|
channel = getattr(self.conn, "remote_conn", None)
|
||||||
|
with self._write_lock:
|
||||||
|
sent = False
|
||||||
|
if channel is not None and hasattr(channel, "send_break") and callable(channel.send_break):
|
||||||
|
try:
|
||||||
|
channel.send_break(0)
|
||||||
|
sent = True
|
||||||
|
except Exception:
|
||||||
|
_log.debug("send_break failed session=%s", self.session_id, exc_info=True)
|
||||||
|
if not sent and channel is not None and hasattr(channel, "send") and callable(channel.send):
|
||||||
|
# Telnet IAC BREAK = 255 243
|
||||||
|
try:
|
||||||
|
channel.send(b"\xff\xf3")
|
||||||
|
sent = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not sent:
|
||||||
|
# Fallback: Ctrl-C often interrupts device CLI more-pages.
|
||||||
|
try:
|
||||||
|
self.conn.write_channel("\x03")
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError("break_failed")
|
||||||
|
self.touch()
|
||||||
|
|
||||||
def resize(self, cols: int, rows: int) -> None:
|
def resize(self, cols: int, rows: int) -> None:
|
||||||
if self.closed or self.conn is None:
|
if self.closed or self.conn is None:
|
||||||
return
|
return
|
||||||
|
|
@ -369,53 +562,76 @@ class WebcrtSession:
|
||||||
return
|
return
|
||||||
channel = getattr(conn, "remote_conn", None)
|
channel = getattr(conn, "remote_conn", None)
|
||||||
hop_return = False
|
hop_return = False
|
||||||
|
poll = max(0.002, float(getattr(settings, "webcrt_reader_poll_sec", 0.01) or 0.01))
|
||||||
try:
|
try:
|
||||||
while not self.closed:
|
while not self.closed:
|
||||||
chunk = b""
|
chunk = b""
|
||||||
try:
|
try:
|
||||||
if channel is not None and hasattr(channel, "recv_ready") and hasattr(channel, "recv"):
|
if channel is not None and hasattr(channel, "recv_ready") and hasattr(channel, "recv"):
|
||||||
# Paramiko SSH: raw bytes keep ANSI / backspace echo intact.
|
# Paramiko SSH: prefer short blocking recv over fixed spin-sleep.
|
||||||
if channel.recv_ready():
|
ready = False
|
||||||
chunk = channel.recv(4096)
|
try:
|
||||||
|
ready = bool(channel.recv_ready())
|
||||||
|
except Exception:
|
||||||
|
ready = False
|
||||||
|
if ready:
|
||||||
|
chunk = channel.recv(16384)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
elif hasattr(channel, "exit_status_ready") and channel.exit_status_ready():
|
elif hasattr(channel, "exit_status_ready") and channel.exit_status_ready():
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
time.sleep(0.04)
|
# Brief block: settimeout + recv wakes sooner than sleep(0.04).
|
||||||
continue
|
prev_timeout = None
|
||||||
|
try:
|
||||||
|
prev_timeout = channel.gettimeout()
|
||||||
|
except Exception:
|
||||||
|
prev_timeout = None
|
||||||
|
try:
|
||||||
|
channel.settimeout(poll)
|
||||||
|
chunk = channel.recv(16384)
|
||||||
|
except Exception:
|
||||||
|
chunk = b""
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
channel.settimeout(prev_timeout)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
elif channel is not None and hasattr(channel, "read_very_eager"):
|
elif channel is not None and hasattr(channel, "read_very_eager"):
|
||||||
# Telnet: do NOT use conn.read_channel() — Netmiko strips ANSI
|
# Telnet: do NOT use conn.read_channel() — Netmiko strips ANSI.
|
||||||
# escape codes, which removes Huawei backspace echo (\x1b[1D \x1b[1D).
|
|
||||||
data = channel.read_very_eager()
|
data = channel.read_very_eager()
|
||||||
if data:
|
if data:
|
||||||
chunk = (
|
chunk = (
|
||||||
data
|
data
|
||||||
if isinstance(data, (bytes, bytearray))
|
if isinstance(data, (bytes, bytearray))
|
||||||
else str(data).encode(
|
else _encode_text(str(data), self.encoding)
|
||||||
getattr(conn, "encoding", None) or "utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
time.sleep(0.04)
|
time.sleep(poll)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
text = conn.read_channel()
|
text = conn.read_channel()
|
||||||
if text:
|
if text:
|
||||||
chunk = text.encode("utf-8", errors="replace")
|
chunk = _encode_text(str(text), self.encoding)
|
||||||
else:
|
else:
|
||||||
time.sleep(0.04)
|
time.sleep(poll)
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if self.closed:
|
if self.closed:
|
||||||
break
|
break
|
||||||
_log.debug("webcrt reader error session=%s: %s", self.session_id, exc)
|
_log.debug("webcrt reader error session=%s: %s", self.session_id, exc)
|
||||||
time.sleep(0.1)
|
time.sleep(0.05)
|
||||||
continue
|
continue
|
||||||
if chunk:
|
if chunk:
|
||||||
self.touch()
|
self.touch()
|
||||||
|
self.bytes_out += len(chunk)
|
||||||
self.out_queue.put(chunk)
|
self.out_queue.put(chunk)
|
||||||
|
try:
|
||||||
|
self.append_session_log(_decode_bytes(chunk, self.encoding))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if self.cli_hop_guard and self._note_cli_hop_output(chunk):
|
if self.cli_hop_guard and self._note_cli_hop_output(chunk):
|
||||||
hop_return = True
|
hop_return = True
|
||||||
notice = (
|
notice = (
|
||||||
|
|
@ -426,7 +642,6 @@ class WebcrtSession:
|
||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
if hop_return and not self.closed:
|
if hop_return and not self.closed:
|
||||||
# Prefer registry close for audit + remove; fall back to local close.
|
|
||||||
try:
|
try:
|
||||||
close_session(self.session_id, reason="cli_hop_return")
|
close_session(self.session_id, reason="cli_hop_return")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -436,12 +651,10 @@ class WebcrtSession:
|
||||||
def _note_cli_hop_output(self, chunk: bytes) -> bool:
|
def _note_cli_hop_output(self, chunk: bytes) -> bool:
|
||||||
"""Accumulate stdout and return True when nested CLI hop has returned to proxy."""
|
"""Accumulate stdout and return True when nested CLI hop has returned to proxy."""
|
||||||
try:
|
try:
|
||||||
text = chunk.decode("utf-8", errors="replace")
|
text = _decode_bytes(chunk, self.encoding)
|
||||||
except Exception:
|
except Exception:
|
||||||
text = str(chunk)
|
text = str(chunk)
|
||||||
self._hop_scan_buf = (self._hop_scan_buf + text)[-12000:]
|
self._hop_scan_buf = (self._hop_scan_buf + text)[-12000:]
|
||||||
# Track a prompt that differs from the hop so same-sysname labs still need
|
|
||||||
# an explicit nested-close message before we tear down.
|
|
||||||
marker = str(self.cli_hop_prompt or "").strip()
|
marker = str(self.cli_hop_prompt or "").strip()
|
||||||
last = extract_cli_prompt_marker(self._hop_scan_buf)
|
last = extract_cli_prompt_marker(self._hop_scan_buf)
|
||||||
if last and (not marker or last != marker):
|
if last and (not marker or last != marker):
|
||||||
|
|
@ -452,11 +665,25 @@ class WebcrtSession:
|
||||||
seen_other_prompt=self._cli_hop_seen_other_prompt,
|
seen_other_prompt=self._cli_hop_seen_other_prompt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def run_post_login_commands(self) -> None:
|
||||||
|
cmds = [str(c).rstrip("\r\n") for c in (self.post_login_commands or []) if str(c).strip()]
|
||||||
|
if not cmds or self.closed or self.conn is None:
|
||||||
|
return
|
||||||
|
for cmd in cmds[:20]:
|
||||||
|
try:
|
||||||
|
self.write_stdin(cmd + "\r")
|
||||||
|
time.sleep(0.15)
|
||||||
|
except Exception:
|
||||||
|
_log.debug("post_login command failed session=%s", self.session_id, exc_info=True)
|
||||||
|
break
|
||||||
|
|
||||||
def close(self, reason: str = "closed") -> None:
|
def close(self, reason: str = "closed") -> None:
|
||||||
if self.closed:
|
if self.closed:
|
||||||
return
|
return
|
||||||
self.closed = True
|
self.closed = True
|
||||||
|
self.state = "closed"
|
||||||
self.close_reason = reason or "closed"
|
self.close_reason = reason or "closed"
|
||||||
|
self._ready_event.set()
|
||||||
try:
|
try:
|
||||||
close_netmiko_connection(self.conn)
|
close_netmiko_connection(self.conn)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -466,6 +693,7 @@ class WebcrtSession:
|
||||||
self.out_queue.put_nowait(None)
|
self.out_queue.put_nowait(None)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
self.close_session_log()
|
||||||
|
|
||||||
|
|
||||||
def _ensure_reaper() -> None:
|
def _ensure_reaper() -> None:
|
||||||
|
|
@ -490,16 +718,32 @@ def _reaper_loop() -> None:
|
||||||
def _reap_sessions() -> None:
|
def _reap_sessions() -> None:
|
||||||
idle = max(60, int(settings.webcrt_idle_timeout_sec or 1800))
|
idle = max(60, int(settings.webcrt_idle_timeout_sec or 1800))
|
||||||
attach = max(10, int(settings.webcrt_attach_timeout_sec or 60))
|
attach = max(10, int(settings.webcrt_attach_timeout_sec or 60))
|
||||||
|
anti_idle = max(0, int(getattr(settings, "webcrt_anti_idle_sec", 0) or 0))
|
||||||
|
anti_payload = str(getattr(settings, "webcrt_anti_idle_payload", " ") or " ")
|
||||||
now = time.time()
|
now = time.time()
|
||||||
to_close: list[tuple[WebcrtSession, str]] = []
|
to_close: list[tuple[WebcrtSession, str]] = []
|
||||||
|
to_nudge: list[WebcrtSession] = []
|
||||||
with _sessions_lock:
|
with _sessions_lock:
|
||||||
for sess in list(_sessions.values()):
|
for sess in list(_sessions.values()):
|
||||||
if sess.closed:
|
if sess.closed:
|
||||||
_sessions.pop(sess.session_id, None)
|
_sessions.pop(sess.session_id, None)
|
||||||
continue
|
continue
|
||||||
|
if sess.state == "connecting":
|
||||||
|
# Connecting sessions use connect timeout, not attach timeout alone.
|
||||||
|
connect_budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 30
|
||||||
|
if (now - sess.connect_started_at) > connect_budget:
|
||||||
|
to_close.append((sess, "connect_timeout"))
|
||||||
|
continue
|
||||||
if sess.attached:
|
if sess.attached:
|
||||||
if (now - sess.last_activity) > idle:
|
if (now - sess.last_activity) > idle:
|
||||||
to_close.append((sess, "idle_timeout"))
|
to_close.append((sess, "idle_timeout"))
|
||||||
|
elif (
|
||||||
|
anti_idle > 0
|
||||||
|
and sess.state == "ready"
|
||||||
|
and sess.conn is not None
|
||||||
|
and (now - sess.last_activity) >= anti_idle
|
||||||
|
):
|
||||||
|
to_nudge.append(sess)
|
||||||
continue
|
continue
|
||||||
# Not attached: either never attached, or briefly detached for reconnect.
|
# Not attached: either never attached, or briefly detached for reconnect.
|
||||||
if sess.detach_deadline is not None:
|
if sess.detach_deadline is not None:
|
||||||
|
|
@ -509,6 +753,18 @@ def _reap_sessions() -> None:
|
||||||
to_close.append((sess, "attach_timeout"))
|
to_close.append((sess, "attach_timeout"))
|
||||||
elif (now - sess.last_activity) > idle:
|
elif (now - sess.last_activity) > idle:
|
||||||
to_close.append((sess, "idle_timeout"))
|
to_close.append((sess, "idle_timeout"))
|
||||||
|
for sess in to_nudge:
|
||||||
|
try:
|
||||||
|
# Touch without changing visible prompt when payload is empty/null-ish.
|
||||||
|
payload = anti_payload
|
||||||
|
if payload == "\\0":
|
||||||
|
payload = "\x00"
|
||||||
|
if payload:
|
||||||
|
sess.write_stdin(payload)
|
||||||
|
else:
|
||||||
|
sess.touch()
|
||||||
|
except Exception:
|
||||||
|
_log.debug("webcrt anti-idle failed session=%s", sess.session_id, exc_info=True)
|
||||||
for sess, reason in to_close:
|
for sess, reason in to_close:
|
||||||
close_session(sess.session_id, reason=reason)
|
close_session(sess.session_id, reason=reason)
|
||||||
|
|
||||||
|
|
@ -526,6 +782,21 @@ def get_session(session_id: str) -> WebcrtSession | None:
|
||||||
return sess
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
def wait_session_ready(session_id: str, *, timeout: float = 120.0) -> WebcrtSession:
|
||||||
|
"""Block until async connect finishes (ready or error). Used by tests and WS."""
|
||||||
|
deadline = time.time() + max(1.0, float(timeout))
|
||||||
|
while time.time() < deadline:
|
||||||
|
sess = get_session(session_id)
|
||||||
|
if sess is None:
|
||||||
|
raise HTTPException(status_code=404, detail="webcrt_session_not_found")
|
||||||
|
if sess.state == "ready":
|
||||||
|
return sess
|
||||||
|
if sess.state == "error":
|
||||||
|
raise HTTPException(status_code=502, detail=sess.connect_error or "connect_failed")
|
||||||
|
sess._ready_event.wait(timeout=0.25)
|
||||||
|
raise HTTPException(status_code=504, detail="connect_timeout")
|
||||||
|
|
||||||
|
|
||||||
def _webcrt_creds_ready(creds: dict[str, Any]) -> bool:
|
def _webcrt_creds_ready(creds: dict[str, Any]) -> bool:
|
||||||
"""True when WebCRT can open a session with the resolved credentials.
|
"""True when WebCRT can open a session with the resolved credentials.
|
||||||
|
|
||||||
|
|
@ -547,6 +818,133 @@ def _webcrt_creds_ready(creds: dict[str, Any]) -> bool:
|
||||||
return bool(str(creds.get("password") or ""))
|
return bool(str(creds.get("password") or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_connect(
|
||||||
|
sess: WebcrtSession,
|
||||||
|
*,
|
||||||
|
creds: dict[str, Any],
|
||||||
|
device: dict[str, Any],
|
||||||
|
connect_timeout: int,
|
||||||
|
client: str,
|
||||||
|
) -> None:
|
||||||
|
log_buf = io.BytesIO()
|
||||||
|
try:
|
||||||
|
conn = open_netmiko_connection(
|
||||||
|
creds,
|
||||||
|
session_timeout=connect_timeout,
|
||||||
|
session_log=log_buf,
|
||||||
|
cols=sess.cols,
|
||||||
|
rows=sess.rows,
|
||||||
|
interactive=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
partial = _session_log_text(log_buf).strip()
|
||||||
|
from .ne_cli_errors import format_cli_failure
|
||||||
|
|
||||||
|
classified = format_cli_failure(exc, partial)
|
||||||
|
detail = f"connect_failed:{classified}"
|
||||||
|
if partial:
|
||||||
|
detail = f"{detail}\n--- device transcript ---\n{partial[-4000:]}"
|
||||||
|
sess.state = "error"
|
||||||
|
sess.connect_error = detail
|
||||||
|
sess.connect_finished_at = time.time()
|
||||||
|
sess._ready_event.set()
|
||||||
|
_audit(
|
||||||
|
"session_open_failed",
|
||||||
|
session_id=sess.session_id,
|
||||||
|
ne_id=sess.ne_id,
|
||||||
|
ne_ip=sess.ne_ip,
|
||||||
|
source=str(device.get("source") or ""),
|
||||||
|
client=client or "",
|
||||||
|
error=str(exc)[:500],
|
||||||
|
transcript_len=len(partial),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
channel = getattr(conn, "remote_conn", None)
|
||||||
|
if channel is not None and hasattr(channel, "resize_pty"):
|
||||||
|
try:
|
||||||
|
channel.resize_pty(width=sess.cols, height=sess.rows)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pre_log = _session_log_text(log_buf)
|
||||||
|
already_prompted = _looks_like_cli_prompt(pre_log)
|
||||||
|
_prime_interactive_channel(conn, already_prompted=already_prompted)
|
||||||
|
bootstrap = prepare_bootstrap_output(_session_log_text(log_buf))
|
||||||
|
if not bootstrap.strip():
|
||||||
|
try:
|
||||||
|
more = _drain_channel(conn, rounds=6, wait=0.08)
|
||||||
|
except Exception:
|
||||||
|
more = ""
|
||||||
|
if more:
|
||||||
|
bootstrap = prepare_bootstrap_output(more)
|
||||||
|
# Slow Cisco VMs leave extra prompt bytes; discard before the live reader starts.
|
||||||
|
try:
|
||||||
|
_drain_raw_channel(conn, duration=0.55)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
hop_guard = get_cli_hop_guard(conn)
|
||||||
|
sess.conn = conn
|
||||||
|
sess.cli_hop_guard = bool(hop_guard)
|
||||||
|
sess.cli_hop_prompt = str((hop_guard or {}).get("hop_prompt") or "")
|
||||||
|
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
|
||||||
|
# Bootstrap already ends at a prompt → never nudge another Enter on WS attach.
|
||||||
|
sess.needs_live_prompt = not _looks_like_cli_prompt(bootstrap)
|
||||||
|
sess.open_session_log()
|
||||||
|
if bootstrap:
|
||||||
|
sess.append_session_log(bootstrap if bootstrap.endswith("\n") else bootstrap + "\n")
|
||||||
|
sess.start_reader()
|
||||||
|
# Drop late prompt echoes that race into the queue right after reader start.
|
||||||
|
prompt_hint = ""
|
||||||
|
if bootstrap:
|
||||||
|
prompt_hint = str(bootstrap).replace("\r\n", "\n").replace("\r", "\n").strip().split("\n")[-1].strip()
|
||||||
|
settle_deadline = time.time() + 0.45
|
||||||
|
while time.time() < settle_deadline:
|
||||||
|
try:
|
||||||
|
chunk = sess.out_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
time.sleep(0.02)
|
||||||
|
continue
|
||||||
|
if chunk is None:
|
||||||
|
sess.out_queue.put(None)
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
text = _decode_bytes(chunk, sess.encoding)
|
||||||
|
except Exception:
|
||||||
|
text = ""
|
||||||
|
if _is_prompt_only_echo(text, prompt_hint):
|
||||||
|
continue
|
||||||
|
# Non-prompt data: put back and stop settling.
|
||||||
|
sess.out_queue.put(chunk)
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
sess.run_post_login_commands()
|
||||||
|
except Exception:
|
||||||
|
_log.debug("post_login failed session=%s", sess.session_id, exc_info=True)
|
||||||
|
sess.state = "ready"
|
||||||
|
sess.connect_finished_at = time.time()
|
||||||
|
sess._ready_event.set()
|
||||||
|
elapsed_ms = int((sess.connect_finished_at - sess.connect_started_at) * 1000)
|
||||||
|
_audit(
|
||||||
|
"session_created",
|
||||||
|
session_id=sess.session_id,
|
||||||
|
ne_id=sess.ne_id,
|
||||||
|
ne_name=sess.ne_name,
|
||||||
|
ne_ip=sess.ne_ip,
|
||||||
|
protocol=sess.protocol,
|
||||||
|
encoding=sess.encoding,
|
||||||
|
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 "",
|
||||||
|
cli_hop_guard=bool(hop_guard),
|
||||||
|
cli_hop_prompt=str((hop_guard or {}).get("hop_prompt") or ""),
|
||||||
|
client=client or "",
|
||||||
|
connect_ms=elapsed_ms,
|
||||||
|
active=active_session_count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_session(
|
def create_session(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -555,6 +953,9 @@ def create_session(
|
||||||
cols: int = 80,
|
cols: int = 80,
|
||||||
rows: int = 24,
|
rows: int = 24,
|
||||||
client: str = "",
|
client: str = "",
|
||||||
|
encoding: str = "utf-8",
|
||||||
|
post_login_commands: list[str] | None = None,
|
||||||
|
async_connect: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from .cli_resolve import resolve_cli_target
|
from .cli_resolve import resolve_cli_target
|
||||||
|
|
||||||
|
|
@ -588,63 +989,8 @@ def create_session(
|
||||||
device_type = str(device.get("device_type") or creds.get("device_type") or "")
|
device_type = str(device.get("device_type") or creds.get("device_type") or "")
|
||||||
vendor = str(device.get("vendor") or creds.get("vendor") or "")
|
vendor = str(device.get("vendor") or creds.get("vendor") or "")
|
||||||
cli_keymap = uses_network_cli_keymap(device_type, vendor)
|
cli_keymap = uses_network_cli_keymap(device_type, vendor)
|
||||||
|
enc = _normalize_encoding(encoding)
|
||||||
|
|
||||||
# Capture real Telnet/SSH login I/O (banner, Username/Password, prompts).
|
|
||||||
log_buf = io.BytesIO()
|
|
||||||
try:
|
|
||||||
conn = open_netmiko_connection(
|
|
||||||
creds,
|
|
||||||
session_timeout=connect_timeout,
|
|
||||||
session_log=log_buf,
|
|
||||||
cols=c,
|
|
||||||
rows=r,
|
|
||||||
interactive=True,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
partial = _session_log_text(log_buf).strip()
|
|
||||||
_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],
|
|
||||||
transcript_len=len(partial),
|
|
||||||
)
|
|
||||||
from .ne_cli_errors import format_cli_failure
|
|
||||||
|
|
||||||
classified = format_cli_failure(exc, partial)
|
|
||||||
detail = f"connect_failed:{classified}"
|
|
||||||
if partial:
|
|
||||||
# Keep detail bounded; UI surfaces this on open failure.
|
|
||||||
detail = f"{detail}\n--- device transcript ---\n{partial[-4000:]}"
|
|
||||||
raise HTTPException(status_code=502, detail=detail) 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
|
|
||||||
|
|
||||||
# Prefer session_log (full login transcript including final prompt).
|
|
||||||
_prime_interactive_channel(conn)
|
|
||||||
bootstrap = prepare_bootstrap_output(_session_log_text(log_buf))
|
|
||||||
if not bootstrap.strip():
|
|
||||||
try:
|
|
||||||
more = _drain_channel(conn, rounds=6, wait=0.1)
|
|
||||||
except Exception:
|
|
||||||
more = ""
|
|
||||||
if more:
|
|
||||||
bootstrap = prepare_bootstrap_output(more)
|
|
||||||
# Discard any unread bytes so the live reader starts clean.
|
|
||||||
try:
|
|
||||||
_drain_channel(conn, rounds=3, wait=0.05)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
hop_guard = get_cli_hop_guard(conn)
|
|
||||||
sess = WebcrtSession(
|
sess = WebcrtSession(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
ne_id=target_id,
|
ne_id=target_id,
|
||||||
|
|
@ -656,34 +1002,51 @@ def create_session(
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
vendor=vendor,
|
vendor=vendor,
|
||||||
cli_keymap=cli_keymap,
|
cli_keymap=cli_keymap,
|
||||||
conn=conn,
|
encoding=enc,
|
||||||
bootstrap_output=str(bootstrap or "").encode("utf-8", errors="replace"),
|
state="connecting",
|
||||||
# Only nudge a live prompt when transcript has no recognizable prompt yet.
|
post_login_commands=list(post_login_commands or [])[:20],
|
||||||
needs_live_prompt=not _looks_like_cli_prompt(bootstrap),
|
|
||||||
cli_hop_guard=bool(hop_guard),
|
|
||||||
cli_hop_prompt=str((hop_guard or {}).get("hop_prompt") or ""),
|
|
||||||
)
|
)
|
||||||
# Keep bootstrap for WS attach replay; do not rely solely on out_queue (StrictMode remount).
|
|
||||||
sess.start_reader()
|
|
||||||
|
|
||||||
with _sessions_lock:
|
with _sessions_lock:
|
||||||
_sessions[session_id] = sess
|
_sessions[session_id] = sess
|
||||||
|
|
||||||
_audit(
|
_audit(
|
||||||
"session_created",
|
"session_connecting",
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
ne_id=sess.ne_id,
|
ne_id=sess.ne_id,
|
||||||
ne_name=sess.ne_name,
|
|
||||||
ne_ip=sess.ne_ip,
|
ne_ip=sess.ne_ip,
|
||||||
protocol=sess.protocol,
|
protocol=sess.protocol,
|
||||||
source=str(device.get("source") or ""),
|
encoding=enc,
|
||||||
hop_enabled=bool(creds.get("hop_enabled")),
|
|
||||||
hop_vendor=str(creds.get("hop_vendor") or "") if creds.get("hop_enabled") else "",
|
|
||||||
cli_hop_guard=bool(hop_guard),
|
|
||||||
cli_hop_prompt=str((hop_guard or {}).get("hop_prompt") or ""),
|
|
||||||
client=client or "",
|
client=client or "",
|
||||||
active=active_session_count(),
|
async_connect=bool(async_connect),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if async_connect:
|
||||||
|
t = threading.Thread(
|
||||||
|
target=_finish_connect,
|
||||||
|
kwargs={
|
||||||
|
"sess": sess,
|
||||||
|
"creds": creds,
|
||||||
|
"device": device,
|
||||||
|
"connect_timeout": connect_timeout,
|
||||||
|
"client": client or "",
|
||||||
|
},
|
||||||
|
name=f"webcrt-connect-{session_id[:8]}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
else:
|
||||||
|
_finish_connect(
|
||||||
|
sess,
|
||||||
|
creds=creds,
|
||||||
|
device=device,
|
||||||
|
connect_timeout=connect_timeout,
|
||||||
|
client=client or "",
|
||||||
|
)
|
||||||
|
if sess.state == "error":
|
||||||
|
with _sessions_lock:
|
||||||
|
_sessions.pop(session_id, None)
|
||||||
|
raise HTTPException(status_code=502, detail=sess.connect_error or "connect_failed")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"ne_id": sess.ne_id,
|
"ne_id": sess.ne_id,
|
||||||
|
|
@ -693,8 +1056,10 @@ def create_session(
|
||||||
"protocol": sess.protocol,
|
"protocol": sess.protocol,
|
||||||
"cols": sess.cols,
|
"cols": sess.cols,
|
||||||
"rows": sess.rows,
|
"rows": sess.rows,
|
||||||
|
"encoding": enc,
|
||||||
|
"state": sess.state,
|
||||||
"ws_path": f"/v1/webcrt/sessions/{session_id}/ws",
|
"ws_path": f"/v1/webcrt/sessions/{session_id}/ws",
|
||||||
"cli_hop": bool(hop_guard),
|
"cli_hop": bool(sess.cli_hop_guard),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -715,6 +1080,7 @@ def mark_attached(session_id: str) -> tuple[WebcrtSession, int]:
|
||||||
ne_id=sess.ne_id,
|
ne_id=sess.ne_id,
|
||||||
ne_ip=sess.ne_ip,
|
ne_ip=sess.ne_ip,
|
||||||
attach_gen=attach_gen,
|
attach_gen=attach_gen,
|
||||||
|
state=sess.state,
|
||||||
)
|
)
|
||||||
return sess, attach_gen
|
return sess, attach_gen
|
||||||
|
|
||||||
|
|
@ -769,6 +1135,9 @@ def close_session(session_id: str, *, reason: str = "closed", client: str = "")
|
||||||
ne_ip=sess.ne_ip,
|
ne_ip=sess.ne_ip,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
client=client or "",
|
client=client or "",
|
||||||
|
bytes_in=sess.bytes_in,
|
||||||
|
bytes_out=sess.bytes_out,
|
||||||
|
queue_dropped=getattr(sess.out_queue, "dropped", 0),
|
||||||
active=active_session_count(),
|
active=active_session_count(),
|
||||||
)
|
)
|
||||||
return {"ok": True, "session_id": session_id, "closed": True, "reason": reason}
|
return {"ok": True, "session_id": session_id, "closed": True, "reason": reason}
|
||||||
|
|
@ -783,9 +1152,20 @@ def list_sessions() -> dict[str, Any]:
|
||||||
"ne_name": s.ne_name,
|
"ne_name": s.ne_name,
|
||||||
"ne_ip": s.ne_ip,
|
"ne_ip": s.ne_ip,
|
||||||
"protocol": s.protocol,
|
"protocol": s.protocol,
|
||||||
|
"encoding": s.encoding,
|
||||||
|
"state": s.state,
|
||||||
"attached": s.attached,
|
"attached": s.attached,
|
||||||
"created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(),
|
"created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(),
|
||||||
"last_activity": datetime.fromtimestamp(s.last_activity, tz=timezone.utc).isoformat(),
|
"last_activity": datetime.fromtimestamp(s.last_activity, tz=timezone.utc).isoformat(),
|
||||||
|
"bytes_in": s.bytes_in,
|
||||||
|
"bytes_out": s.bytes_out,
|
||||||
|
"queue_depth": s.out_queue.qsize(),
|
||||||
|
"queue_dropped": getattr(s.out_queue, "dropped", 0),
|
||||||
|
"connect_ms": (
|
||||||
|
int((s.connect_finished_at - s.connect_started_at) * 1000)
|
||||||
|
if s.connect_finished_at
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for s in _sessions.values()
|
for s in _sessions.values()
|
||||||
if not s.closed
|
if not s.closed
|
||||||
|
|
@ -794,5 +1174,7 @@ def list_sessions() -> dict[str, Any]:
|
||||||
"total": len(items),
|
"total": len(items),
|
||||||
"max_sessions": max(1, int(settings.webcrt_max_sessions or 20)),
|
"max_sessions": max(1, int(settings.webcrt_max_sessions or 20)),
|
||||||
"idle_timeout_sec": max(60, int(settings.webcrt_idle_timeout_sec or 1800)),
|
"idle_timeout_sec": max(60, int(settings.webcrt_idle_timeout_sec or 1800)),
|
||||||
|
"keepalive_sec": int(getattr(settings, "webcrt_keepalive_sec", 30) or 0),
|
||||||
|
"anti_idle_sec": int(getattr(settings, "webcrt_anti_idle_sec", 0) or 0),
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
189
netx_api/webcrt_sftp.py
Normal file
189
netx_api/webcrt_sftp.py
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
"""Lightweight SFTP helpers for WebCRT (SSH targets only; separate from interactive PTY)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import posixpath
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .cli_resolve import resolve_cli_target
|
||||||
|
from .config import settings
|
||||||
|
from .ne_crypto import CredentialCryptoError
|
||||||
|
from .webcrt_service import _webcrt_creds_ready
|
||||||
|
|
||||||
|
_log = logging.getLogger("netx.webcrt.sftp")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_ssh_direct(creds: dict[str, Any], device: dict[str, Any]) -> None:
|
||||||
|
protocol = str(device.get("protocol") or creds.get("protocol") or "ssh").lower()
|
||||||
|
if protocol != "ssh":
|
||||||
|
raise HTTPException(status_code=400, detail="sftp_requires_ssh")
|
||||||
|
if creds.get("hop_enabled"):
|
||||||
|
# Keep v1 simple: SFTP only for direct SSH (no hop/proxy jump).
|
||||||
|
raise HTTPException(status_code=400, detail="sftp_hop_not_supported")
|
||||||
|
|
||||||
|
|
||||||
|
def _open_sftp(creds: dict[str, Any]) -> tuple[paramiko.SSHClient, paramiko.SFTPClient]:
|
||||||
|
timeout = int(settings.ne_connect_timeout_sec or 30)
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
str(creds["ip_address"]),
|
||||||
|
port=int(creds.get("port") or 22),
|
||||||
|
username=str(creds["username"]),
|
||||||
|
password=str(creds["password"]),
|
||||||
|
timeout=timeout,
|
||||||
|
banner_timeout=timeout,
|
||||||
|
auth_timeout=timeout,
|
||||||
|
look_for_keys=False,
|
||||||
|
allow_agent=False,
|
||||||
|
)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
return client, sftp
|
||||||
|
except HTTPException:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=502, detail=f"sftp_connect_failed:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(db: Session, *, managed_ne_id: str | None, ume_ne_id: str | None) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
creds, device = resolve_cli_target(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
|
||||||
|
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 _webcrt_creds_ready(creds):
|
||||||
|
raise HTTPException(status_code=400, detail="credentials_incomplete")
|
||||||
|
_require_ssh_direct(creds, device)
|
||||||
|
return creds, device
|
||||||
|
|
||||||
|
|
||||||
|
def sftp_list(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
managed_ne_id: str | None,
|
||||||
|
ume_ne_id: str | None,
|
||||||
|
path: str = ".",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
creds, device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
|
||||||
|
remote = str(path or ".").strip() or "."
|
||||||
|
client, sftp = _open_sftp(creds)
|
||||||
|
try:
|
||||||
|
entries = []
|
||||||
|
for attr in sftp.listdir_attr(remote):
|
||||||
|
mode = int(getattr(attr, "st_mode", 0) or 0)
|
||||||
|
is_dir = bool(mode & 0o40000)
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"name": attr.filename,
|
||||||
|
"size": int(getattr(attr, "st_size", 0) or 0),
|
||||||
|
"mtime": int(getattr(attr, "st_mtime", 0) or 0),
|
||||||
|
"is_dir": is_dir,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
entries.sort(key=lambda x: (not x["is_dir"], str(x["name"]).lower()))
|
||||||
|
return {
|
||||||
|
"ne_id": str(device.get("id") or ""),
|
||||||
|
"ne_name": str(device.get("name") or ""),
|
||||||
|
"path": remote,
|
||||||
|
"items": entries,
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"sftp_list_failed:{exc}") from exc
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
sftp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sftp_download(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
managed_ne_id: str | None,
|
||||||
|
ume_ne_id: str | None,
|
||||||
|
path: str,
|
||||||
|
) -> tuple[bytes, str]:
|
||||||
|
creds, _device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
|
||||||
|
remote = str(path or "").strip()
|
||||||
|
if not remote or remote.endswith("/"):
|
||||||
|
raise HTTPException(status_code=400, detail="sftp_path_required")
|
||||||
|
client, sftp = _open_sftp(creds)
|
||||||
|
try:
|
||||||
|
with sftp.open(remote, "rb") as fh:
|
||||||
|
data = fh.read(8 * 1024 * 1024 + 1)
|
||||||
|
if len(data) > 8 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=413, detail="sftp_file_too_large")
|
||||||
|
return data, posixpath.basename(remote) or "download.bin"
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"sftp_download_failed:{exc}") from exc
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
sftp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def sftp_upload(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
managed_ne_id: str | None,
|
||||||
|
ume_ne_id: str | None,
|
||||||
|
remote_path: str,
|
||||||
|
data: bytes,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
creds, device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id)
|
||||||
|
remote = str(remote_path or "").strip()
|
||||||
|
if not remote:
|
||||||
|
raise HTTPException(status_code=400, detail="sftp_path_required")
|
||||||
|
client, sftp = _open_sftp(creds)
|
||||||
|
try:
|
||||||
|
with sftp.open(remote, "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"ne_id": str(device.get("id") or ""),
|
||||||
|
"path": remote,
|
||||||
|
"size": len(data),
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"sftp_upload_failed:{exc}") from exc
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
sftp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
@ -15,10 +15,13 @@ class _FakeConn:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.written: list[str] = []
|
self.written: list[str] = []
|
||||||
self.RETURN = "\n"
|
self.RETURN = "\n"
|
||||||
self.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
|
self.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty", "send_break", "send"])
|
||||||
self.remote_conn.recv_ready.return_value = False
|
self.remote_conn.recv_ready.return_value = False
|
||||||
self.remote_conn.exit_status_ready.return_value = False
|
self.remote_conn.exit_status_ready.return_value = False
|
||||||
self.remote_conn.resize_pty = MagicMock()
|
self.remote_conn.resize_pty = MagicMock()
|
||||||
|
self.remote_conn.send_break = MagicMock()
|
||||||
|
# No send by default so write_stdin uses write_channel in unit tests.
|
||||||
|
del self.remote_conn.send
|
||||||
|
|
||||||
def write_channel(self, data: str) -> None:
|
def write_channel(self, data: str) -> None:
|
||||||
self.written.append(data)
|
self.written.append(data)
|
||||||
|
|
@ -68,6 +71,9 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
self.assertEqual(conn.written[-1], "\x1b[D")
|
self.assertEqual(conn.written[-1], "\x1b[D")
|
||||||
sess.resize(120, 40)
|
sess.resize(120, 40)
|
||||||
conn.remote_conn.resize_pty.assert_called_with(width=120, height=40)
|
conn.remote_conn.resize_pty.assert_called_with(width=120, height=40)
|
||||||
|
conn.remote_conn.send_break = MagicMock()
|
||||||
|
sess.send_break()
|
||||||
|
conn.remote_conn.send_break.assert_called()
|
||||||
sess.close("test")
|
sess.close("test")
|
||||||
self.assertTrue(sess.closed)
|
self.assertTrue(sess.closed)
|
||||||
|
|
||||||
|
|
@ -90,6 +96,11 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
self.assertEqual(svc.normalize_cli_transcript("banner\nR2#R2#"), "banner\nR2#")
|
self.assertEqual(svc.normalize_cli_transcript("banner\nR2#R2#"), "banner\nR2#")
|
||||||
self.assertEqual(svc.prepare_bootstrap_output("login\nR2#\nR2#"), "login\nR2#")
|
self.assertEqual(svc.prepare_bootstrap_output("login\nR2#\nR2#"), "login\nR2#")
|
||||||
self.assertTrue(svc.prepare_bootstrap_output("login\nR2#").endswith("R2#"))
|
self.assertTrue(svc.prepare_bootstrap_output("login\nR2#").endswith("R2#"))
|
||||||
|
# Slow VM / prime Enter can leave three identical prompts.
|
||||||
|
self.assertEqual(svc.prepare_bootstrap_output("banner\nR2#\nR2#\nR2#"), "banner\nR2#")
|
||||||
|
self.assertEqual(svc.prepare_bootstrap_output("banner\nR2#\n\nR2#"), "banner\nR2#")
|
||||||
|
self.assertTrue(svc._is_prompt_only_echo("\r\nR2#\r\n", "R2#"))
|
||||||
|
self.assertFalse(svc._is_prompt_only_echo("R2#show clock\r\n", "R2#"))
|
||||||
|
|
||||||
@patch.object(svc, "_audit")
|
@patch.object(svc, "_audit")
|
||||||
@patch.object(svc, "open_netmiko_connection")
|
@patch.object(svc, "open_netmiko_connection")
|
||||||
|
|
@ -120,11 +131,12 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
||||||
with patch.object(svc.settings, "webcrt_max_sessions", 1):
|
with patch.object(svc.settings, "webcrt_max_sessions", 1):
|
||||||
out = svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test")
|
out = svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test", async_connect=False)
|
||||||
self.assertIn("session_id", out)
|
self.assertIn("session_id", out)
|
||||||
|
self.assertEqual(out.get("state"), "ready")
|
||||||
self.assertFalse(out.get("cli_hop"))
|
self.assertFalse(out.get("cli_hop"))
|
||||||
with self.assertRaises(HTTPException) as ctx:
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test")
|
svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test", async_connect=False)
|
||||||
self.assertEqual(ctx.exception.status_code, 429)
|
self.assertEqual(ctx.exception.status_code, 429)
|
||||||
|
|
||||||
@patch.object(svc, "_audit")
|
@patch.object(svc, "_audit")
|
||||||
|
|
@ -172,7 +184,7 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
mock_open.side_effect = _open_with_log
|
mock_open.side_effect = _open_with_log
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
out = svc.create_session(db, ne_id="ne-hop", cols=100, rows=30, client="test")
|
out = svc.create_session(db, ne_id="ne-hop", cols=100, rows=30, client="test", async_connect=False)
|
||||||
mock_open.assert_called_once()
|
mock_open.assert_called_once()
|
||||||
called_creds = mock_open.call_args.args[0]
|
called_creds = mock_open.call_args.args[0]
|
||||||
self.assertTrue(called_creds["hop_enabled"])
|
self.assertTrue(called_creds["hop_enabled"])
|
||||||
|
|
@ -222,13 +234,15 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
mock_open.side_effect = lambda *a, **k: _FakeConn()
|
mock_open.side_effect = lambda *a, **k: _FakeConn()
|
||||||
mock_guard.return_value = {"hop_prompt": "<HOP>", "hop_vendor": "huawei", "hop_host": "1.1.1.1"}
|
mock_guard.return_value = {"hop_prompt": "<HOP>", "hop_vendor": "huawei", "hop_host": "1.1.1.1"}
|
||||||
out = svc.create_session(MagicMock(), ne_id="ne-cli-hop", cols=100, rows=30, client="test")
|
out = svc.create_session(
|
||||||
self.assertTrue(out.get("cli_hop"))
|
MagicMock(), ne_id="ne-cli-hop", cols=100, rows=30, client="test", async_connect=False
|
||||||
self.assertEqual(mock_open.call_args.kwargs.get("cols"), 100)
|
)
|
||||||
self.assertEqual(mock_open.call_args.kwargs.get("rows"), 30)
|
|
||||||
sess = svc.get_session(out["session_id"])
|
sess = svc.get_session(out["session_id"])
|
||||||
assert sess is not None
|
assert sess is not None
|
||||||
self.assertTrue(sess.cli_hop_guard)
|
self.assertTrue(sess.cli_hop_guard)
|
||||||
|
self.assertTrue(out.get("cli_hop") or sess.cli_hop_guard)
|
||||||
|
self.assertEqual(mock_open.call_args.kwargs.get("cols"), 100)
|
||||||
|
self.assertEqual(mock_open.call_args.kwargs.get("rows"), 30)
|
||||||
self.assertEqual(sess.cli_hop_prompt, "<HOP>")
|
self.assertEqual(sess.cli_hop_prompt, "<HOP>")
|
||||||
svc.close_session(out["session_id"], reason="test")
|
svc.close_session(out["session_id"], reason="test")
|
||||||
|
|
||||||
|
|
@ -265,7 +279,9 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mock_open.return_value = _FakeConn()
|
mock_open.return_value = _FakeConn()
|
||||||
out = svc.create_session(MagicMock(), ne_id="ne-bastion", cols=80, rows=24, client="test")
|
out = svc.create_session(
|
||||||
|
MagicMock(), ne_id="ne-bastion", cols=80, rows=24, client="test", async_connect=False
|
||||||
|
)
|
||||||
mock_open.assert_called_once()
|
mock_open.assert_called_once()
|
||||||
self.assertEqual(out["ne_id"], "ne-bastion")
|
self.assertEqual(out["ne_id"], "ne-bastion")
|
||||||
svc.close_session(out["session_id"], reason="test")
|
svc.close_session(out["session_id"], reason="test")
|
||||||
|
|
@ -466,6 +482,19 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
sess._note_cli_hop_output(b"Connection closed by foreign host\r\n<HUAWEI>\r\n")
|
sess._note_cli_hop_output(b"Connection closed by foreign host\r\n<HUAWEI>\r\n")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_bounded_queue_drops_oldest(self) -> None:
|
||||||
|
q = svc._BoundedByteQueue(maxsize=8)
|
||||||
|
for i in range(10):
|
||||||
|
q.put(str(i).encode())
|
||||||
|
self.assertGreaterEqual(q.dropped, 2)
|
||||||
|
first = q.get_nowait()
|
||||||
|
self.assertEqual(first, b"2")
|
||||||
|
|
||||||
|
def test_normalize_encoding(self) -> None:
|
||||||
|
self.assertEqual(svc._normalize_encoding("GBK"), "gbk")
|
||||||
|
self.assertEqual(svc._normalize_encoding("utf8"), "utf-8")
|
||||||
|
self.assertEqual(svc._encode_text("测", "gbk")[:1], b"\xb2")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
10
web/WEB.md
10
web/WEB.md
|
|
@ -139,12 +139,14 @@ src/
|
||||||
|
|
||||||
## WebCRT
|
## WebCRT
|
||||||
|
|
||||||
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
|
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`;可选 `encoding`/`post_login_commands`/`async_connect`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
|
||||||
|
- SFTP(直连 SSH):`POST /v1/webcrt/sftp/list|download|upload`
|
||||||
- 目标列表复用 `/v1/cli/targets`(托管 + UME,搜索分页;`source=all|managed|ume`)
|
- 目标列表复用 `/v1/cli/targets`(托管 + UME,搜索分页;`source=all|managed|ume`)
|
||||||
- 凭据:托管走网元自身账号;UME 走 CLI 连接模板(`resolve_cli_target`)
|
- 凭据:托管走网元自身账号;UME 走 CLI 连接模板(`resolve_cli_target`)
|
||||||
- 前端:CRT 风格左右分栏(会话管理 + 多标签终端)
|
- 前端:CRT 风格左右分栏(收藏/最近 + 多标签终端);粘贴节流、选区复制、Break、Ctrl+F、Button Bar、广播、编码/字号
|
||||||
- 审计:`NETX_WEBCRT_DATA_DIR`(默认 `data/webcrt/audit.jsonl`)
|
- WS:stdout 二进制帧合批;stdin 短合批;建连可异步(POST 立即返回,进度经 WS `connecting`→`connected`)
|
||||||
- 限流:`NETX_WEBCRT_MAX_SESSIONS`、`NETX_WEBCRT_IDLE_TIMEOUT_SEC`
|
- 审计 / 会话日志:`NETX_WEBCRT_DATA_DIR`(`audit.jsonl` + `sessions/*.log`)
|
||||||
|
- 限流 / 保活:`NETX_WEBCRT_MAX_SESSIONS`、`NETX_WEBCRT_IDLE_TIMEOUT_SEC`、`NETX_WEBCRT_KEEPALIVE_SEC`、`NETX_WEBCRT_ANTI_IDLE_SEC`
|
||||||
|
|
||||||
## Toast
|
## Toast
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
@ -6,9 +6,14 @@ import "@xterm/xterm/css/xterm.css";
|
||||||
export type WebTerminalHandle = {
|
export type WebTerminalHandle = {
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
copyAll: () => Promise<string>;
|
copyAll: () => Promise<string>;
|
||||||
|
copySelection: () => Promise<string>;
|
||||||
getText: () => string;
|
getText: () => string;
|
||||||
fit: () => void;
|
fit: () => void;
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
|
sendBreak: () => void;
|
||||||
|
sendText: (data: string, opts?: { throttle?: boolean }) => void;
|
||||||
|
findNext: (term: string) => void;
|
||||||
|
findPrevious: (term: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -16,6 +21,9 @@ type Props = {
|
||||||
title?: string;
|
title?: string;
|
||||||
recording?: boolean;
|
recording?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
encoding?: string;
|
||||||
|
pasteDelayMs?: number;
|
||||||
|
copyOnSelect?: boolean;
|
||||||
onStatus?: (state: string, message?: string) => void;
|
onStatus?: (state: string, message?: string) => void;
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
onStdout?: (data: string) => void;
|
onStdout?: (data: string) => void;
|
||||||
|
|
@ -35,11 +43,10 @@ function serializeTerminal(term: Terminal): string {
|
||||||
function isSidebarSearchTarget(target: EventTarget | null): boolean {
|
function isSidebarSearchTarget(target: EventTarget | null): boolean {
|
||||||
if (!(target instanceof HTMLElement)) return false;
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") {
|
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") {
|
||||||
// xterm's hidden textarea must still receive keys normally.
|
|
||||||
if (target.classList.contains("xterm-helper-textarea")) return false;
|
if (target.classList.contains("xterm-helper-textarea")) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return Boolean(target.closest(".webcrt-sidebar__search"));
|
return Boolean(target.closest(".webcrt-sidebar__search") || target.closest(".webcrt-ctx") || target.closest(".webcrt-find"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isXtermTextarea(target: EventTarget | null): boolean {
|
function isXtermTextarea(target: EventTarget | null): boolean {
|
||||||
|
|
@ -49,7 +56,7 @@ function isXtermTextarea(target: EventTarget | null): boolean {
|
||||||
/** Map a browser key event to the bytes xterm/onData would normally emit. */
|
/** Map a browser key event to the bytes xterm/onData would normally emit. */
|
||||||
function keyEventToStdin(e: KeyboardEvent): string | null {
|
function keyEventToStdin(e: KeyboardEvent): string | null {
|
||||||
if (e.ctrlKey || e.altKey || e.metaKey) return null;
|
if (e.ctrlKey || e.altKey || e.metaKey) return null;
|
||||||
if (e.key === "Backspace") return "\x08"; // BS — common SecureCRT/VT default
|
if (e.key === "Backspace") return "\x08";
|
||||||
if (e.key === "Enter") return "\r";
|
if (e.key === "Enter") return "\r";
|
||||||
if (e.key === "Tab") return "\t";
|
if (e.key === "Tab") return "\t";
|
||||||
if (e.key === "Escape") return "\x1b";
|
if (e.key === "Escape") return "\x1b";
|
||||||
|
|
@ -64,19 +71,65 @@ function keyEventToStdin(e: KeyboardEvent): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeBytes(buf: ArrayBuffer, _encoding: string): string {
|
||||||
|
// Server normalizes device encodings (e.g. GBK) to UTF-8 on the wire.
|
||||||
|
try {
|
||||||
|
return new TextDecoder("utf-8").decode(buf);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREF_KEY = "netx.webcrt.termPrefs";
|
||||||
|
|
||||||
|
function loadPrefs(): { copyOnSelect: boolean; pasteDelayMs: number } {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PREF_KEY);
|
||||||
|
if (!raw) return { copyOnSelect: true, pasteDelayMs: 40 };
|
||||||
|
const j = JSON.parse(raw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>;
|
||||||
|
return {
|
||||||
|
copyOnSelect: j.copyOnSelect !== false,
|
||||||
|
pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs) || 40)),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { copyOnSelect: true, pasteDelayMs: 40 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
|
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
|
||||||
{ wsUrl, title, recording, autoFocus = true, onStatus, onReady, onStdout },
|
{
|
||||||
|
wsUrl,
|
||||||
|
title,
|
||||||
|
recording,
|
||||||
|
autoFocus = true,
|
||||||
|
encoding = "utf-8",
|
||||||
|
pasteDelayMs,
|
||||||
|
copyOnSelect,
|
||||||
|
onStatus,
|
||||||
|
onReady,
|
||||||
|
onStdout,
|
||||||
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const findIndexRef = useRef(0);
|
||||||
const onStatusRef = useRef(onStatus);
|
const onStatusRef = useRef(onStatus);
|
||||||
const onReadyRef = useRef(onReady);
|
const onReadyRef = useRef(onReady);
|
||||||
const onStdoutRef = useRef(onStdout);
|
const onStdoutRef = useRef(onStdout);
|
||||||
const recordingRef = useRef(!!recording);
|
const recordingRef = useRef(!!recording);
|
||||||
const autoFocusRef = useRef(autoFocus);
|
const autoFocusRef = useRef(autoFocus);
|
||||||
|
const encodingRef = useRef(encoding);
|
||||||
|
const pasteDelayRef = useRef(pasteDelayMs ?? loadPrefs().pasteDelayMs);
|
||||||
|
const copyOnSelectRef = useRef(copyOnSelect ?? loadPrefs().copyOnSelect);
|
||||||
|
const pasteQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [findOpen, setFindOpen] = useState(false);
|
||||||
|
const [findQuery, setFindQuery] = useState("");
|
||||||
|
const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null);
|
||||||
|
const findInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStatusRef.current = onStatus;
|
onStatusRef.current = onStatus;
|
||||||
|
|
@ -92,6 +145,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
autoFocusRef.current = autoFocus;
|
autoFocusRef.current = autoFocus;
|
||||||
}, [autoFocus]);
|
}, [autoFocus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
encodingRef.current = encoding;
|
||||||
|
}, [encoding]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pasteDelayMs != null) pasteDelayRef.current = pasteDelayMs;
|
||||||
|
}, [pasteDelayMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (copyOnSelect != null) copyOnSelectRef.current = copyOnSelect;
|
||||||
|
}, [copyOnSelect]);
|
||||||
|
|
||||||
const focusTerminal = () => {
|
const focusTerminal = () => {
|
||||||
try {
|
try {
|
||||||
termRef.current?.focus();
|
termRef.current?.focus();
|
||||||
|
|
@ -100,6 +165,46 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendJson = (payload: Record<string, unknown>) => {
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendStdinImmediate = (data: string) => {
|
||||||
|
if (!data) return;
|
||||||
|
sendJson({ type: "stdin", data });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** SecureCRT-like paste: send line-by-line with delay; show progress while sending. */
|
||||||
|
const sendStdinThrottled = (data: string) => {
|
||||||
|
if (!data) return;
|
||||||
|
const delay = pasteDelayRef.current;
|
||||||
|
if (delay <= 0 || data.length < 8) {
|
||||||
|
sendStdinImmediate(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pasteQueueRef.current = pasteQueueRef.current.then(async () => {
|
||||||
|
const lines = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||||
|
const total = Math.max(1, lines.length);
|
||||||
|
setPasteStatus({ done: 0, total });
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
const chunk = i < lines.length - 1 ? `${line}\r` : line;
|
||||||
|
if (chunk) sendStdinImmediate(chunk);
|
||||||
|
setPasteStatus({ done: i + 1, total });
|
||||||
|
if (i < lines.length - 1) {
|
||||||
|
await new Promise((r) => window.setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPasteStatus(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
clear: () => {
|
clear: () => {
|
||||||
termRef.current?.clear();
|
termRef.current?.clear();
|
||||||
|
|
@ -112,6 +217,13 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
},
|
},
|
||||||
|
copySelection: async () => {
|
||||||
|
const text = termRef.current?.getSelection() || "";
|
||||||
|
if (text && navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
|
getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
|
||||||
fit: () => {
|
fit: () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -121,6 +233,39 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
focus: focusTerminal,
|
focus: focusTerminal,
|
||||||
|
sendBreak: () => sendJson({ type: "break" }),
|
||||||
|
sendText: (data: string, opts?: { throttle?: boolean }) => {
|
||||||
|
if (opts?.throttle) sendStdinThrottled(data);
|
||||||
|
else sendStdinImmediate(data);
|
||||||
|
},
|
||||||
|
findNext: (q: string) => {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term || !q) return;
|
||||||
|
const text = serializeTerminal(term).toLowerCase();
|
||||||
|
const needle = q.toLowerCase();
|
||||||
|
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||||
|
if (idx < 0) idx = text.indexOf(needle);
|
||||||
|
if (idx >= 0) {
|
||||||
|
findIndexRef.current = idx;
|
||||||
|
// Approximate scroll: each buffer line ~1 row.
|
||||||
|
const line = text.slice(0, idx).split("\n").length - 1;
|
||||||
|
term.scrollToLine(Math.max(0, line - 2));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
findPrevious: (q: string) => {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term || !q) return;
|
||||||
|
const text = serializeTerminal(term).toLowerCase();
|
||||||
|
const needle = q.toLowerCase();
|
||||||
|
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||||
|
let idx = before.lastIndexOf(needle);
|
||||||
|
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||||
|
if (idx >= 0) {
|
||||||
|
findIndexRef.current = idx;
|
||||||
|
const line = text.slice(0, idx).split("\n").length - 1;
|
||||||
|
term.scrollToLine(Math.max(0, line - 2));
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -131,10 +276,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: 'Consolas, "Courier New", monospace',
|
fontFamily: 'Consolas, "Courier New", monospace',
|
||||||
|
scrollback: 10000,
|
||||||
theme: {
|
theme: {
|
||||||
background: "#0b1220",
|
background: "#0b1220",
|
||||||
foreground: "#e2e8f0",
|
foreground: "#e2e8f0",
|
||||||
cursor: "#e2e8f0",
|
cursor: "#e2e8f0",
|
||||||
|
selectionBackground: "#334155",
|
||||||
},
|
},
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
});
|
});
|
||||||
|
|
@ -155,7 +302,6 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
if (!autoFocusRef.current) return;
|
if (!autoFocusRef.current) return;
|
||||||
focusTerminal();
|
focusTerminal();
|
||||||
};
|
};
|
||||||
// Focus immediately so the first keystroke is not lost to the sidebar/body.
|
|
||||||
maybeFocus();
|
maybeFocus();
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
doFit();
|
doFit();
|
||||||
|
|
@ -167,22 +313,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
});
|
});
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
onStatusRef.current?.("connecting");
|
onStatusRef.current?.("connecting");
|
||||||
|
|
||||||
const sendJson = (payload: Record<string, unknown>) => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendStdin = (data: string) => {
|
|
||||||
if (!data) return;
|
|
||||||
sendJson({ type: "stdin", data });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Display only what the device echoes (chars, Tab completion, BS erase, etc.).
|
|
||||||
// No local echo / local erase — those desync on Tab complete and prompt redraw.
|
|
||||||
const writeStdout = (raw: string) => {
|
const writeStdout = (raw: string) => {
|
||||||
if (raw) term.write(raw);
|
if (raw) term.write(raw);
|
||||||
if (recordingRef.current) onStdoutRef.current?.(raw);
|
if (recordingRef.current) onStdoutRef.current?.(raw);
|
||||||
|
|
@ -201,6 +335,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
|
if (ev.data instanceof ArrayBuffer) {
|
||||||
|
writeStdout(decodeBytes(ev.data, encodingRef.current));
|
||||||
|
maybeFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
||||||
|
void ev.data.arrayBuffer().then((buf) => {
|
||||||
|
writeStdout(decodeBytes(buf, encodingRef.current));
|
||||||
|
maybeFocus();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(String(ev.data || "{}")) as {
|
const msg = JSON.parse(String(ev.data || "{}")) as {
|
||||||
type?: string;
|
type?: string;
|
||||||
|
|
@ -215,7 +361,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
}
|
}
|
||||||
if (msg.type === "status") {
|
if (msg.type === "status") {
|
||||||
onStatusRef.current?.(String(msg.state || ""), msg.message);
|
onStatusRef.current?.(String(msg.state || ""), msg.message);
|
||||||
if (msg.state === "connected") {
|
if (msg.state === "connected" || msg.state === "connecting") {
|
||||||
maybeFocus();
|
maybeFocus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -243,27 +389,41 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// When focused, xterm onData sends keystrokes.
|
|
||||||
const dataDisposable = term.onData((data) => {
|
const dataDisposable = term.onData((data) => {
|
||||||
// Normalize Backspace: xterm emits DEL(0x7f); devices expect BS(0x08) like default SecureCRT.
|
|
||||||
const normalized = data.replace(/\x7f/g, "\x08");
|
const normalized = data.replace(/\x7f/g, "\x08");
|
||||||
sendStdin(normalized);
|
// Large pastes from xterm arrive as one onData blob.
|
||||||
|
if (normalized.length > 32 || normalized.includes("\r") || normalized.includes("\n")) {
|
||||||
|
sendStdinThrottled(normalized);
|
||||||
|
} else {
|
||||||
|
sendStdinImmediate(normalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selDisposable = term.onSelectionChange(() => {
|
||||||
|
if (!copyOnSelectRef.current) return;
|
||||||
|
const sel = term.getSelection();
|
||||||
|
if (sel && navigator.clipboard?.writeText) {
|
||||||
|
void navigator.clipboard.writeText(sel).catch(() => undefined);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// When NOT focused (sidebar still focused after click), capture keys and forward
|
|
||||||
// so the first character / Backspace are not lost to the browser.
|
|
||||||
const onKeyDownCapture = (e: KeyboardEvent) => {
|
const onKeyDownCapture = (e: KeyboardEvent) => {
|
||||||
if (!autoFocusRef.current) return;
|
if (!autoFocusRef.current) return;
|
||||||
if (host.closest("[hidden]")) return;
|
if (host.closest("[hidden]")) return;
|
||||||
if (isSidebarSearchTarget(e.target)) return;
|
if (isSidebarSearchTarget(e.target)) return;
|
||||||
|
|
||||||
// Always block browser "Backspace = history back" while this session pane is active.
|
if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F")) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFindOpen(true);
|
||||||
|
window.setTimeout(() => findInputRef.current?.focus(), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === "Backspace") {
|
if (e.key === "Backspace") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isXtermTextarea(e.target)) {
|
if (isXtermTextarea(e.target)) {
|
||||||
// Let xterm onData handle it; we only prevented browser back above.
|
|
||||||
maybeFocus();
|
maybeFocus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -274,10 +434,31 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
maybeFocus();
|
maybeFocus();
|
||||||
sendStdin(data);
|
sendStdinImmediate(data);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDownCapture, true);
|
window.addEventListener("keydown", onKeyDownCapture, true);
|
||||||
|
|
||||||
|
const onContextMenu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCtxMenu({ x: e.clientX, y: e.clientY });
|
||||||
|
};
|
||||||
|
host.addEventListener("contextmenu", onContextMenu);
|
||||||
|
|
||||||
|
const onPasteCapture = (e: ClipboardEvent) => {
|
||||||
|
if (!autoFocusRef.current) return;
|
||||||
|
if (host.closest("[hidden]")) return;
|
||||||
|
if (isSidebarSearchTarget(e.target)) return;
|
||||||
|
const text = e.clipboardData?.getData("text") || "";
|
||||||
|
if (!text) return;
|
||||||
|
// Intercept paste so we always apply delay (even if focus is odd).
|
||||||
|
if (document.activeElement === term.textarea || host.contains(document.activeElement) || autoFocusRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendStdinThrottled(text);
|
||||||
|
maybeFocus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("paste", onPasteCapture, true);
|
||||||
|
|
||||||
const pingTimer = window.setInterval(() => {
|
const pingTimer = window.setInterval(() => {
|
||||||
sendJson({ type: "ping" });
|
sendJson({ type: "ping" });
|
||||||
}, 25000);
|
}, 25000);
|
||||||
|
|
@ -297,8 +478,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
window.clearInterval(pingTimer);
|
window.clearInterval(pingTimer);
|
||||||
window.removeEventListener("resize", onWinResize);
|
window.removeEventListener("resize", onWinResize);
|
||||||
window.removeEventListener("keydown", onKeyDownCapture, true);
|
window.removeEventListener("keydown", onKeyDownCapture, true);
|
||||||
|
window.removeEventListener("paste", onPasteCapture, true);
|
||||||
|
host.removeEventListener("contextmenu", onContextMenu);
|
||||||
ro?.disconnect();
|
ro?.disconnect();
|
||||||
dataDisposable.dispose();
|
dataDisposable.dispose();
|
||||||
|
selDisposable.dispose();
|
||||||
try {
|
try {
|
||||||
ws.close();
|
ws.close();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -311,13 +495,147 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
};
|
};
|
||||||
}, [wsUrl, title]);
|
}, [wsUrl, title]);
|
||||||
|
|
||||||
|
const pasteFromClipboard = async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
sendStdinThrottled(text);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
setCtxMenu(null);
|
||||||
|
focusTerminal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copySelection = async () => {
|
||||||
|
const text = termRef.current?.getSelection() || "";
|
||||||
|
if (text && navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
setCtxMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="webcrt-term-wrap">
|
||||||
className="webcrt-term"
|
{pasteStatus ? (
|
||||||
ref={hostRef}
|
<div className="webcrt-paste-status" role="status" aria-live="polite">
|
||||||
onMouseDown={() => {
|
粘贴中 {pasteStatus.done}/{pasteStatus.total} 行…
|
||||||
focusTerminal();
|
</div>
|
||||||
}}
|
) : null}
|
||||||
/>
|
{findOpen ? (
|
||||||
|
<div className="webcrt-find">
|
||||||
|
<input
|
||||||
|
ref={findInputRef}
|
||||||
|
type="search"
|
||||||
|
value={findQuery}
|
||||||
|
placeholder="Find…"
|
||||||
|
onChange={(e) => setFindQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term || !findQuery) return;
|
||||||
|
const text = serializeTerminal(term).toLowerCase();
|
||||||
|
const needle = findQuery.toLowerCase();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||||
|
let idx = before.lastIndexOf(needle);
|
||||||
|
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||||
|
if (idx >= 0) {
|
||||||
|
findIndexRef.current = idx;
|
||||||
|
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||||
|
if (idx < 0) idx = text.indexOf(needle);
|
||||||
|
if (idx >= 0) {
|
||||||
|
findIndexRef.current = idx;
|
||||||
|
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setFindOpen(false);
|
||||||
|
focusTerminal();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term || !findQuery) return;
|
||||||
|
const text = serializeTerminal(term).toLowerCase();
|
||||||
|
const needle = findQuery.toLowerCase();
|
||||||
|
const before = text.slice(0, Math.max(0, findIndexRef.current));
|
||||||
|
let idx = before.lastIndexOf(needle);
|
||||||
|
if (idx < 0) idx = text.lastIndexOf(needle);
|
||||||
|
if (idx >= 0) {
|
||||||
|
findIndexRef.current = idx;
|
||||||
|
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term || !findQuery) return;
|
||||||
|
const text = serializeTerminal(term).toLowerCase();
|
||||||
|
const needle = findQuery.toLowerCase();
|
||||||
|
let idx = text.indexOf(needle, findIndexRef.current + 1);
|
||||||
|
if (idx < 0) idx = text.indexOf(needle);
|
||||||
|
if (idx >= 0) {
|
||||||
|
findIndexRef.current = idx;
|
||||||
|
term.scrollToLine(Math.max(0, text.slice(0, idx).split("\n").length - 3));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFindOpen(false);
|
||||||
|
focusTerminal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="webcrt-term"
|
||||||
|
ref={hostRef}
|
||||||
|
onMouseDown={() => {
|
||||||
|
focusTerminal();
|
||||||
|
setCtxMenu(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{ctxMenu ? (
|
||||||
|
<div
|
||||||
|
className="webcrt-ctx"
|
||||||
|
style={{ left: ctxMenu.x, top: ctxMenu.y }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={() => void copySelection()}>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void pasteFromClipboard()}>
|
||||||
|
Paste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
termRef.current?.clear();
|
||||||
|
termRef.current?.write("\x1b[H\x1b[2J");
|
||||||
|
setCtxMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -939,6 +939,25 @@ const en = {
|
||||||
copied: "Copied to clipboard",
|
copied: "Copied to clipboard",
|
||||||
copyEmpty: "Terminal is empty",
|
copyEmpty: "Terminal is empty",
|
||||||
copyFailed: "Copy failed",
|
copyFailed: "Copy failed",
|
||||||
|
break: "Break",
|
||||||
|
breakSent: "Break sent",
|
||||||
|
broadcast: "Broadcast",
|
||||||
|
broadcastSent: "Sent to all sessions",
|
||||||
|
postLogin: "Post-login cmds",
|
||||||
|
},
|
||||||
|
favorites: "Favorites",
|
||||||
|
recent: "Recent",
|
||||||
|
favorite: "Toggle favorite",
|
||||||
|
encoding: "Encoding",
|
||||||
|
fontSize: "Font",
|
||||||
|
broadcastPh: "Send to all tabs…",
|
||||||
|
buttonBarHint: "One per line: label|command",
|
||||||
|
postLoginPrompt: "Post-login commands (one per line, this device)",
|
||||||
|
postLoginSaved: "Post-login commands saved",
|
||||||
|
sftp: {
|
||||||
|
refresh: "Refresh",
|
||||||
|
upload: "Upload",
|
||||||
|
uploaded: "Upload complete",
|
||||||
},
|
},
|
||||||
err: {
|
err: {
|
||||||
sessionLimit: "Session limit reached; close other terminals first",
|
sessionLimit: "Session limit reached; close other terminals first",
|
||||||
|
|
@ -946,6 +965,8 @@ const en = {
|
||||||
credsIncomplete: "Incomplete username/password on this NE",
|
credsIncomplete: "Incomplete username/password on this NE",
|
||||||
cliProfile: "UME NEs require a CLI connect profile (default account / hop)",
|
cliProfile: "UME NEs require a CLI connect profile (default account / hop)",
|
||||||
connectFailed: "Device login failed: {{detail}}",
|
connectFailed: "Device login failed: {{detail}}",
|
||||||
|
sftpHop: "SFTP does not support hop devices yet (direct SSH only)",
|
||||||
|
sftpSsh: "SFTP requires SSH",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
topology: {
|
topology: {
|
||||||
|
|
|
||||||
|
|
@ -936,6 +936,25 @@ const zh = {
|
||||||
copied: "已复制到剪贴板",
|
copied: "已复制到剪贴板",
|
||||||
copyEmpty: "终端内容为空",
|
copyEmpty: "终端内容为空",
|
||||||
copyFailed: "复制失败",
|
copyFailed: "复制失败",
|
||||||
|
break: "Break",
|
||||||
|
breakSent: "已发送 Break",
|
||||||
|
broadcast: "广播",
|
||||||
|
broadcastSent: "已广播到全部会话",
|
||||||
|
postLogin: "登录后命令",
|
||||||
|
},
|
||||||
|
favorites: "收藏",
|
||||||
|
recent: "最近",
|
||||||
|
favorite: "收藏/取消",
|
||||||
|
encoding: "编码",
|
||||||
|
fontSize: "字号",
|
||||||
|
broadcastPh: "发送到全部标签…",
|
||||||
|
buttonBarHint: "每行:按钮名|命令",
|
||||||
|
postLoginPrompt: "登录后自动执行(每行一条,仅本设备)",
|
||||||
|
postLoginSaved: "登录后命令已保存",
|
||||||
|
sftp: {
|
||||||
|
refresh: "刷新",
|
||||||
|
upload: "上传",
|
||||||
|
uploaded: "上传成功",
|
||||||
},
|
},
|
||||||
err: {
|
err: {
|
||||||
sessionLimit: "并发会话已达上限,请先关闭其他终端",
|
sessionLimit: "并发会话已达上限,请先关闭其他终端",
|
||||||
|
|
@ -943,6 +962,8 @@ const zh = {
|
||||||
credsIncomplete: "网元账号或密码不完整",
|
credsIncomplete: "网元账号或密码不完整",
|
||||||
cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)",
|
cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)",
|
||||||
connectFailed: "登录设备失败:{{detail}}",
|
connectFailed: "登录设备失败:{{detail}}",
|
||||||
|
sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH",
|
||||||
|
sftpSsh: "SFTP 仅支持 SSH 协议",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
topology: {
|
topology: {
|
||||||
|
|
|
||||||
|
|
@ -2289,7 +2289,8 @@ pre {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.webcrt-main__pane .webcrt-term {
|
.webcrt-main__pane .webcrt-term,
|
||||||
|
.webcrt-main__pane .webcrt-term-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
|
|
@ -2332,6 +2333,284 @@ pre {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.webcrt-term-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-term-wrap .webcrt-term {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-paste-status {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 4;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
border: 1px solid #475569;
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-find {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #1e293b;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-find input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-find button {
|
||||||
|
background: #334155;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-ctx {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 120px;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-ctx button {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #e2e8f0;
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-ctx button:hover {
|
||||||
|
background: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-tree__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-tree__fav {
|
||||||
|
flex: 0 0 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-tree__fav:hover {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-tree__row .webcrt-tree__item {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sidebar__pins {
|
||||||
|
max-height: 28%;
|
||||||
|
overflow: auto;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sidebar__pin-title {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 6px 10px 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sidebar__prefs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sidebar__prefs label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sidebar__prefs select,
|
||||||
|
.webcrt-sidebar__prefs input {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-buttonbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #1e293b;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-buttonbar__btn {
|
||||||
|
background: #334155;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-buttonbar__btn:hover {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-broadcast {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-broadcast input {
|
||||||
|
width: 160px;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-broadcast button {
|
||||||
|
background: #0ea5e9;
|
||||||
|
border: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-buttonbar-edit {
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #0f172a;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-buttonbar-edit textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: min(320px, 40%);
|
||||||
|
background: #0f172a;
|
||||||
|
border-left: 1px solid #334155;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__bar input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__bar button,
|
||||||
|
.webcrt-sftp__upload {
|
||||||
|
background: #334155;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__list button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webcrt-sftp__list button:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.webcrt-shell {
|
.webcrt-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import {
|
||||||
createWebcrtSession,
|
createWebcrtSession,
|
||||||
fetchCliTargets,
|
fetchCliTargets,
|
||||||
fetchManagedNeById,
|
fetchManagedNeById,
|
||||||
|
webcrtSftpDownload,
|
||||||
|
webcrtSftpList,
|
||||||
|
webcrtSftpUpload,
|
||||||
webcrtWsUrl,
|
webcrtWsUrl,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
import { pageCount } from "../utils/display";
|
import { pageCount } from "../utils/display";
|
||||||
|
|
@ -50,6 +53,8 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string,
|
||||||
if (raw.includes("credentials_incomplete") || raw.includes("cli_username_required")) return t("webcrt.err.credsIncomplete");
|
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("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:/, "") });
|
if (raw.includes("connect_failed")) return t("webcrt.err.connectFailed", { detail: raw.replace(/^.*connect_failed:/, "") });
|
||||||
|
if (raw.includes("sftp_hop_not_supported")) return t("webcrt.err.sftpHop");
|
||||||
|
if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh");
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +81,9 @@ export function WebcrtPage() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [tabs, setTabs] = useState<TermTab[]>([]);
|
const [tabs, setTabs] = useState<TermTab[]>([]);
|
||||||
const [activeTabKey, setActiveTabKey] = useState("");
|
const [activeTabKey, setActiveTabKey] = useState("");
|
||||||
|
const [sftpOpen, setSftpOpen] = useState(false);
|
||||||
|
const [sftpPath, setSftpPath] = useState(".");
|
||||||
|
const [sftpItems, setSftpItems] = useState<Array<{ name: string; size: number; mtime: number; is_dir: boolean }>>([]);
|
||||||
const connectingKeysRef = useRef<Set<string>>(new Set());
|
const connectingKeysRef = useRef<Set<string>>(new Set());
|
||||||
const tabsRef = useRef<TermTab[]>([]);
|
const tabsRef = useRef<TermTab[]>([]);
|
||||||
tabsRef.current = tabs;
|
tabsRef.current = tabs;
|
||||||
|
|
@ -145,8 +153,18 @@ export function WebcrtPage() {
|
||||||
const rows = Math.max(24, Math.floor((window.innerHeight - 180) / 18));
|
const rows = Math.max(24, Math.floor((window.innerHeight - 180) / 18));
|
||||||
const body =
|
const body =
|
||||||
target.source === "ume"
|
target.source === "ume"
|
||||||
? { ume_ne_id: target.ume_ne_id || target.id, cols, rows }
|
? {
|
||||||
: { ne_id: target.id, cols, rows };
|
ume_ne_id: target.ume_ne_id || target.id,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
async_connect: true,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
ne_id: target.id,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
async_connect: true,
|
||||||
|
};
|
||||||
const sess = await createWebcrtSession(body);
|
const sess = await createWebcrtSession(body);
|
||||||
const wsUrl = webcrtWsUrl(sess.session_id);
|
const wsUrl = webcrtWsUrl(sess.session_id);
|
||||||
updateTab(key, {
|
updateTab(key, {
|
||||||
|
|
@ -228,7 +246,9 @@ export function WebcrtPage() {
|
||||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||||
if (!tab) return;
|
if (!tab) return;
|
||||||
try {
|
try {
|
||||||
const text = await termRefs.current.get(tab.key)?.copyAll();
|
const handle = termRefs.current.get(tab.key);
|
||||||
|
const sel = await handle?.copySelection();
|
||||||
|
const text = sel || (await handle?.copyAll());
|
||||||
if (text) showOk(t("webcrt.actions.copied"));
|
if (text) showOk(t("webcrt.actions.copied"));
|
||||||
else showError(t("webcrt.actions.copyEmpty"));
|
else showError(t("webcrt.actions.copyEmpty"));
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -236,6 +256,22 @@ export function WebcrtPage() {
|
||||||
}
|
}
|
||||||
}, [activeTabKey, showOk, showError, t]);
|
}, [activeTabKey, showOk, showError, t]);
|
||||||
|
|
||||||
|
const refreshSftp = useCallback(async () => {
|
||||||
|
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||||
|
if (!tab) return;
|
||||||
|
try {
|
||||||
|
const body =
|
||||||
|
tab.target.source === "ume"
|
||||||
|
? { ume_ne_id: tab.target.ume_ne_id || tab.target.id, path: sftpPath }
|
||||||
|
: { ne_id: tab.target.id, path: sftpPath };
|
||||||
|
const res = await webcrtSftpList(body);
|
||||||
|
setSftpItems(res.items || []);
|
||||||
|
setSftpPath(res.path || sftpPath);
|
||||||
|
} catch (err) {
|
||||||
|
showError(webcrtErrorMessage(err, t));
|
||||||
|
}
|
||||||
|
}, [activeTabKey, showError, sftpPath, t]);
|
||||||
|
|
||||||
// Auto-connect from /webcrt?ne_id=...
|
// Auto-connect from /webcrt?ne_id=...
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!presetNeId) return;
|
if (!presetNeId) return;
|
||||||
|
|
@ -292,6 +328,35 @@ export function WebcrtPage() {
|
||||||
|
|
||||||
const activeTab = tabs.find((x) => x.key === activeTabKey) || null;
|
const activeTab = tabs.find((x) => x.key === activeTabKey) || null;
|
||||||
|
|
||||||
|
const renderDeviceRow = (row: CliTargetItem) => {
|
||||||
|
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${activeTabKey === key ? " is-active" : ""}`}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => void openTarget(row)}
|
||||||
|
title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
|
||||||
|
>
|
||||||
|
<span className="webcrt-tree__icon" aria-hidden>
|
||||||
|
<ComputerIcon />
|
||||||
|
</span>
|
||||||
|
<span className="webcrt-tree__label">
|
||||||
|
<span className="webcrt-tree__name">{deviceLabel(row)}</span>
|
||||||
|
<span className="webcrt-tree__meta">
|
||||||
|
{row.ip_address || row.source}
|
||||||
|
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
|
||||||
|
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="webcrt-shell">
|
<div className="webcrt-shell">
|
||||||
<aside className="webcrt-sidebar">
|
<aside className="webcrt-sidebar">
|
||||||
|
|
@ -326,39 +391,7 @@ export function WebcrtPage() {
|
||||||
{!targetsQuery.isLoading && items.length === 0 ? (
|
{!targetsQuery.isLoading && items.length === 0 ? (
|
||||||
<div className="webcrt-tree__empty">{t("webcrt.empty")}</div>
|
<div className="webcrt-tree__empty">{t("webcrt.empty")}</div>
|
||||||
) : null}
|
) : null}
|
||||||
<ul className="webcrt-tree__list">
|
<ul className="webcrt-tree__list">{items.map((row) => renderDeviceRow(row))}</ul>
|
||||||
{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${activeTabKey === key ? " is-active" : ""}`}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
// Keep focus off the list button so Enter/Backspace go to the CRT.
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
onClick={() => void openTarget(row)}
|
|
||||||
title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
|
|
||||||
>
|
|
||||||
<span className="webcrt-tree__icon" aria-hidden>
|
|
||||||
<ComputerIcon />
|
|
||||||
</span>
|
|
||||||
<span className="webcrt-tree__label">
|
|
||||||
<span className="webcrt-tree__name">{deviceLabel(row)}</span>
|
|
||||||
<span className="webcrt-tree__meta">
|
|
||||||
{row.ip_address || row.source}
|
|
||||||
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
|
|
||||||
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="webcrt-sidebar__pager">
|
<div className="webcrt-sidebar__pager">
|
||||||
<span>{t("common.pagerMeta", { total, page, pages })}</span>
|
<span>{t("common.pagerMeta", { total, page, pages })}</span>
|
||||||
|
|
@ -435,6 +468,16 @@ export function WebcrtPage() {
|
||||||
<span aria-hidden>⧉</span>
|
<span aria-hidden>⧉</span>
|
||||||
{t("webcrt.actions.copy")}
|
{t("webcrt.actions.copy")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`webcrt-action-btn${sftpOpen ? " is-active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSftpOpen((v) => !v);
|
||||||
|
if (!sftpOpen) void refreshSftp();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SFTP
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="webcrt-main__body">
|
<div className="webcrt-main__body">
|
||||||
|
|
@ -476,6 +519,8 @@ export function WebcrtPage() {
|
||||||
if (activeTabKey === tab.key) {
|
if (activeTabKey === tab.key) {
|
||||||
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
|
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
|
||||||
}
|
}
|
||||||
|
} else if (state === "connecting") {
|
||||||
|
updateTab(tab.key, { status: "connecting" });
|
||||||
} else if (state === "error") updateTab(tab.key, { status: "error" });
|
} else if (state === "error") updateTab(tab.key, { status: "error" });
|
||||||
else if (state === "closed") updateTab(tab.key, { status: "closed" });
|
else if (state === "closed") updateTab(tab.key, { status: "closed" });
|
||||||
}}
|
}}
|
||||||
|
|
@ -483,6 +528,89 @@ export function WebcrtPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{sftpOpen && activeTab ? (
|
||||||
|
<div className="webcrt-sftp">
|
||||||
|
<div className="webcrt-sftp__bar">
|
||||||
|
<input value={sftpPath} onChange={(e) => setSftpPath(e.target.value)} />
|
||||||
|
<button type="button" onClick={() => void refreshSftp()}>
|
||||||
|
{t("webcrt.sftp.refresh")}
|
||||||
|
</button>
|
||||||
|
<label className="webcrt-sftp__upload">
|
||||||
|
{t("webcrt.sftp.upload")}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || !activeTab) return;
|
||||||
|
const remote = `${sftpPath.replace(/\/$/, "")}/${file.name}`.replace(/^\.\//, "");
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const body =
|
||||||
|
activeTab.target.source === "ume"
|
||||||
|
? {
|
||||||
|
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
|
||||||
|
remote_path: remote,
|
||||||
|
file,
|
||||||
|
}
|
||||||
|
: { ne_id: activeTab.target.id, remote_path: remote, file };
|
||||||
|
await webcrtSftpUpload(body);
|
||||||
|
showOk(t("webcrt.sftp.uploaded"));
|
||||||
|
await refreshSftp();
|
||||||
|
} catch (err) {
|
||||||
|
showError(webcrtErrorMessage(err, t));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<ul className="webcrt-sftp__list">
|
||||||
|
{sftpItems.map((it) => (
|
||||||
|
<li key={it.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (it.is_dir) {
|
||||||
|
const next = `${sftpPath.replace(/\/$/, "")}/${it.name}`.replace(/^\.\//, "");
|
||||||
|
setSftpPath(next);
|
||||||
|
window.setTimeout(() => void refreshSftp(), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const body =
|
||||||
|
activeTab.target.source === "ume"
|
||||||
|
? {
|
||||||
|
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
|
||||||
|
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
ne_id: activeTab.target.id,
|
||||||
|
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
|
||||||
|
};
|
||||||
|
const blob = await webcrtSftpDownload(body);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = it.name;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
showError(webcrtErrorMessage(err, t));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.is_dir ? "📁" : "📄"} {it.name}
|
||||||
|
{!it.is_dir ? ` (${it.size})` : ""}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -459,6 +459,8 @@ export type WebcrtSessionCreateResult = {
|
||||||
cols: number;
|
cols: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
ws_path: string;
|
ws_path: string;
|
||||||
|
encoding?: string;
|
||||||
|
state?: string;
|
||||||
/** True when session used a vendor CLI hop (nested stelnet/telnet). */
|
/** True when session used a vendor CLI hop (nested stelnet/telnet). */
|
||||||
cli_hop?: boolean;
|
cli_hop?: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -468,6 +470,9 @@ export const createWebcrtSession = (body: {
|
||||||
ume_ne_id?: string;
|
ume_ne_id?: string;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
encoding?: string;
|
||||||
|
post_login_commands?: string[];
|
||||||
|
async_connect?: boolean;
|
||||||
}) => apiPost<WebcrtSessionCreateResult>("/v1/webcrt/sessions", body);
|
}) => apiPost<WebcrtSessionCreateResult>("/v1/webcrt/sessions", body);
|
||||||
|
|
||||||
export const closeWebcrtSession = (sessionId: string) =>
|
export const closeWebcrtSession = (sessionId: string) =>
|
||||||
|
|
@ -475,6 +480,66 @@ export const closeWebcrtSession = (sessionId: string) =>
|
||||||
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
|
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type WebcrtSftpListResult = {
|
||||||
|
ne_id: string;
|
||||||
|
ne_name: string;
|
||||||
|
path: string;
|
||||||
|
items: Array<{ name: string; size: number; mtime: number; is_dir: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>
|
||||||
|
apiPost<WebcrtSftpListResult>("/v1/webcrt/sftp/list", body);
|
||||||
|
|
||||||
|
export async function webcrtSftpDownload(body: {
|
||||||
|
ne_id?: string;
|
||||||
|
ume_ne_id?: string;
|
||||||
|
path: string;
|
||||||
|
}): Promise<Blob> {
|
||||||
|
const path = "/v1/webcrt/sftp/download";
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
handleUnauthorized(path);
|
||||||
|
throw new Error("401 unauthorized");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function webcrtSftpUpload(body: {
|
||||||
|
ne_id?: string;
|
||||||
|
ume_ne_id?: string;
|
||||||
|
remote_path: string;
|
||||||
|
file: File;
|
||||||
|
}): Promise<{ ok: boolean; path: string; size: number }> {
|
||||||
|
const path = "/v1/webcrt/sftp/upload";
|
||||||
|
const fd = new FormData();
|
||||||
|
if (body.ne_id) fd.append("ne_id", body.ne_id);
|
||||||
|
if (body.ume_ne_id) fd.append("ume_ne_id", body.ume_ne_id);
|
||||||
|
fd.append("remote_path", body.remote_path);
|
||||||
|
fd.append("file", body.file);
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
handleUnauthorized(path);
|
||||||
|
throw new Error("401 unauthorized");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as { ok: boolean; path: string; size: number };
|
||||||
|
}
|
||||||
|
|
||||||
export const webcrtWsUrl = (sessionId: string): string => {
|
export const webcrtWsUrl = (sessionId: string): string => {
|
||||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;
|
const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue