feat: responsive web terminal with auto-reconnect, heartbeat, and local echo
Implements a resilient, responsive web terminal that survives network blips, provides instant typing feedback, and restores scrollback on reconnect. Backend changes: - Add heartbeat tracking (15s ping interval, 60s idle timeout) - Add message batching (16ms flush window) for efficient I/O - Add termios echo detection and set_echo_state control messages - Add graceful session_ended notification before close - Add ping/pong protocol support Frontend changes: - Rewrite TerminalComponent with status bar, connection indicator, session-ended overlay, reconnect banner, and ResizeObserver - Add useTerminalConnection hook with: - Exponential backoff auto-reconnect (1s → 30s max, 10 attempts) - Heartbeat/ping-pong with latency tracking - Local echo for printable ASCII with server deduplication - Resize debounce (200ms) + throttle (500ms) - Scrollback serialization via xterm-addon-serialize - Ctrl+Shift+R manual reconnect shortcut - Add WebSocket protocol types and encoding utilities - Add xterm-addon-serialize dependency Tests: - 16 backend unit tests (TerminalSession + TerminalManager) - 13 frontend hook tests (connection lifecycle, reconnect, resize, scrollback, callbacks) Quality gates: - Frontend typecheck: clean - Frontend lint: clean - Frontend tests: 48 passed - Backend unit tests: 101 passed - Backend ruff: clean SDD artifacts: openspec/changes/responsive-terminal/
This commit is contained in:
@@ -4,7 +4,7 @@ import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi import APIRouter, Depends, WebSocket
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_db_session
|
||||
@@ -26,6 +26,11 @@ async def terminal_websocket(
|
||||
"""WebSocket endpoint for terminal access to a tool instance.
|
||||
|
||||
Provides an interactive terminal session inside a running tool instance container.
|
||||
Supports:
|
||||
- Auto-reconnection (client reconnects, server spawns new session)
|
||||
- Heartbeat ping/pong
|
||||
- Binary and text input frames
|
||||
- Graceful session end notifications
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection.
|
||||
@@ -34,26 +39,27 @@ async def terminal_websocket(
|
||||
|
||||
Returns:
|
||||
None. Communicates via WebSocket messages.
|
||||
|
||||
"""
|
||||
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
|
||||
await websocket.accept()
|
||||
|
||||
try:
|
||||
# Parse instance_id
|
||||
instance_uuid = uuid.UUID(instance_id)
|
||||
except ValueError:
|
||||
logger.error("Invalid instance ID: %s", instance_id)
|
||||
await websocket.close(code=4001, reason="Invalid instance ID")
|
||||
return
|
||||
|
||||
# Authenticate user from session cookie
|
||||
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||
if user_id is None:
|
||||
logger.warning("Unauthorized terminal access attempt for instance %s", instance_id)
|
||||
logger.warning(
|
||||
"Unauthorized terminal access attempt for instance %s",
|
||||
instance_id,
|
||||
)
|
||||
await websocket.close(code=4003, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Get instance and verify ownership
|
||||
instance = await db_session.get(ToolInstance, instance_uuid)
|
||||
if instance is None:
|
||||
logger.warning("Instance %s not found", instance_id)
|
||||
@@ -61,38 +67,65 @@ async def terminal_websocket(
|
||||
return
|
||||
|
||||
if instance.owner_id != user_id:
|
||||
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
|
||||
logger.warning(
|
||||
"Forbidden terminal access for instance %s by user %s",
|
||||
instance_id,
|
||||
user_id,
|
||||
)
|
||||
await websocket.close(code=4003, reason="Forbidden")
|
||||
return
|
||||
|
||||
if instance.status != "running" or not instance.container_id:
|
||||
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
|
||||
logger.warning(
|
||||
"Instance %s not running (status=%s, container_id=%s)",
|
||||
instance_id,
|
||||
instance.status,
|
||||
instance.container_id,
|
||||
)
|
||||
await websocket.close(code=4004, reason="Instance not running")
|
||||
return
|
||||
|
||||
logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id)
|
||||
# Create terminal session
|
||||
logger.info(
|
||||
"Creating terminal session for instance %s (container_id=%s)",
|
||||
instance_id,
|
||||
instance.container_id,
|
||||
)
|
||||
try:
|
||||
session = await terminal_manager.create_session(
|
||||
instance_uuid,
|
||||
instance.container_id,
|
||||
websocket,
|
||||
)
|
||||
logger.info("Terminal session created successfully for instance %s", instance_id)
|
||||
logger.info(
|
||||
"Terminal session created successfully for instance %s",
|
||||
instance_id,
|
||||
)
|
||||
|
||||
# Send connected status
|
||||
await websocket.send_json({"type": "status", "status": "connected"})
|
||||
|
||||
# Keep connection alive until session ends
|
||||
# The terminal_manager handles I/O loops, we just wait here
|
||||
while session.is_alive() and not session._closed:
|
||||
await asyncio.sleep(0.5)
|
||||
# Monitor session health and echo state
|
||||
while session.is_alive() and not session.closed:
|
||||
# Check echo state periodically
|
||||
new_echo_state = await session.check_echo_state()
|
||||
if new_echo_state is not None:
|
||||
await websocket.send_json(
|
||||
{"type": "set_echo_state", "enabled": new_echo_state},
|
||||
)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||
# Session ended — determine reason and notify client
|
||||
exit_reason = session.get_exit_reason() or "process_exit"
|
||||
await websocket.send_json({"type": "session_ended", "reason": exit_reason})
|
||||
await websocket.close(code=1000, reason=f"Session ended: {exit_reason}")
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Terminal session error for instance %s",
|
||||
instance_id,
|
||||
)
|
||||
await websocket.close(code=4000, reason="Terminal session error")
|
||||
finally:
|
||||
# Cleanup will be handled by the session manager
|
||||
pass
|
||||
|
||||
|
||||
@@ -108,6 +141,7 @@ async def _get_user_from_websocket(
|
||||
|
||||
Returns:
|
||||
The user's UUID if authenticated, None otherwise.
|
||||
|
||||
"""
|
||||
from src.auth.session import decode_session_cookie
|
||||
from src.config import Settings
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
"""Terminal session manager for WebSocket connections."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Coroutine
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from src.services.terminal_session import TerminalSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_READ_BATCH_INTERVAL_S = 0.016 # 16ms max batching delay
|
||||
_READ_POLL_TIMEOUT_S = 0.005
|
||||
_READ_POLL_SLEEP_S = 0.001
|
||||
_HEARTBEAT_INTERVAL_S = 15.0
|
||||
_IDLE_TIMEOUT_S = 60.0
|
||||
|
||||
|
||||
class TerminalManager:
|
||||
"""Manages active terminal sessions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialise the terminal manager."""
|
||||
self._sessions: dict[str, TerminalSession] = {}
|
||||
self._last_client_message: dict[str, float] = {}
|
||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
@@ -26,55 +42,134 @@ class TerminalManager:
|
||||
session = TerminalSession(session_id, instance_id, container_id)
|
||||
await session.start()
|
||||
self._sessions[session_id] = session
|
||||
self._last_client_message[session_id] = time.monotonic()
|
||||
|
||||
# Start background tasks for I/O streaming
|
||||
asyncio.create_task(self._read_loop(session, websocket))
|
||||
asyncio.create_task(self._write_loop(session, websocket))
|
||||
self._start_task(self._read_loop(session, websocket))
|
||||
self._start_task(self._write_loop(session, websocket))
|
||||
self._start_task(self._heartbeat_loop(session, websocket))
|
||||
|
||||
return session
|
||||
|
||||
async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
||||
"""Read output from the container and send to WebSocket."""
|
||||
def _start_task(self, coro: Coroutine[Any, Any, None]) -> None:
|
||||
"""Start a background task and store a reference to prevent GC."""
|
||||
task = asyncio.create_task(coro)
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
async def _read_loop(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Read output from the container and send to WebSocket with batching."""
|
||||
try:
|
||||
while session.is_alive() and not session._closed:
|
||||
data = await session.read_output()
|
||||
buffer = bytearray()
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while session.is_alive() and not session.closed:
|
||||
data = await session.read_output(select_timeout=_READ_POLL_TIMEOUT_S)
|
||||
if data:
|
||||
await websocket.send_bytes(data)
|
||||
else:
|
||||
await asyncio.sleep(0.01)
|
||||
buffer.extend(data)
|
||||
|
||||
now = time.monotonic()
|
||||
flush_due = buffer and (
|
||||
now - last_flush >= _READ_BATCH_INTERVAL_S or not data
|
||||
)
|
||||
|
||||
if flush_due:
|
||||
await websocket.send_bytes(bytes(buffer))
|
||||
buffer.clear()
|
||||
last_flush = now
|
||||
elif not data:
|
||||
await asyncio.sleep(_READ_POLL_SLEEP_S)
|
||||
|
||||
# Flush any remaining data
|
||||
if buffer:
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.send_bytes(bytes(buffer))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("Read loop error for session %s", session.session_id)
|
||||
finally:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
|
||||
async def _write_loop(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Read input from WebSocket and send to container."""
|
||||
try:
|
||||
while session.is_alive() and not session._closed:
|
||||
while session.is_alive() and not session.closed:
|
||||
message = await websocket.receive()
|
||||
self._last_client_message[session.session_id] = time.monotonic()
|
||||
|
||||
if message["type"] == "websocket.receive":
|
||||
if "bytes" in message:
|
||||
await session.write_input(message["bytes"])
|
||||
elif "text" in message:
|
||||
text = message["text"]
|
||||
if text.startswith("{"):
|
||||
# Control message (JSON)
|
||||
import json
|
||||
try:
|
||||
ctrl = json.loads(text)
|
||||
if ctrl.get("type") == "resize":
|
||||
await session.resize(
|
||||
ctrl.get("cols", 80),
|
||||
ctrl.get("rows", 24),
|
||||
)
|
||||
await self._handle_control_message(
|
||||
session,
|
||||
websocket,
|
||||
ctrl,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
logger.debug("Invalid JSON control message: %s", text)
|
||||
else:
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
elif message["type"] == "websocket.disconnect":
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
logger.exception("Write loop error for session %s", session.session_id)
|
||||
finally:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
async def _handle_control_message(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
ctrl: dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle a JSON control message from the client."""
|
||||
msg_type = ctrl.get("type")
|
||||
if msg_type == "resize":
|
||||
await session.resize(
|
||||
ctrl.get("cols", 80),
|
||||
ctrl.get("rows", 24),
|
||||
)
|
||||
elif msg_type == "ping":
|
||||
await websocket.send_json(
|
||||
{"type": "pong", "id": ctrl.get("id")},
|
||||
)
|
||||
|
||||
async def _heartbeat_loop(
|
||||
self,
|
||||
session: TerminalSession,
|
||||
websocket: WebSocket,
|
||||
) -> None:
|
||||
"""Monitor client activity and close idle connections."""
|
||||
try:
|
||||
while session.is_alive() and not session.closed:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
||||
last_msg = self._last_client_message.get(session.session_id, 0)
|
||||
if time.monotonic() - last_msg > _IDLE_TIMEOUT_S:
|
||||
# Client has been silent for 60s — close connection
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.close(
|
||||
code=1000,
|
||||
reason="Idle timeout",
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Heartbeat loop error for session %s",
|
||||
session.session_id,
|
||||
)
|
||||
finally:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
@@ -82,12 +177,14 @@ class TerminalManager:
|
||||
"""Clean up a session."""
|
||||
if session.session_id in self._sessions:
|
||||
del self._sessions[session.session_id]
|
||||
self._last_client_message.pop(session.session_id, None)
|
||||
await session.close()
|
||||
|
||||
async def close_all(self) -> None:
|
||||
"""Close all active sessions."""
|
||||
sessions = list(self._sessions.values())
|
||||
self._sessions.clear()
|
||||
self._last_client_message.clear()
|
||||
for session in sessions:
|
||||
await session.close()
|
||||
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
"""Terminal session management for tool instances."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import struct
|
||||
import fcntl
|
||||
import termios
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TerminalSession:
|
||||
"""Manages a single terminal session connected to a docker container."""
|
||||
|
||||
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
instance_id: uuid.UUID,
|
||||
container_id: str,
|
||||
) -> None:
|
||||
"""Initialize a terminal session."""
|
||||
self.session_id = session_id
|
||||
self.instance_id = instance_id
|
||||
self.container_id = container_id
|
||||
@@ -21,23 +31,20 @@ class TerminalSession:
|
||||
self._closed = False
|
||||
self._master_fd: int | None = None
|
||||
self._slave_fd: int | None = None
|
||||
self._echo_enabled = True
|
||||
self._exit_reason: str | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the docker exec process with a shell using a PTY."""
|
||||
# Create a pseudo-terminal on the host
|
||||
self._master_fd, self._slave_fd = pty.openpty()
|
||||
|
||||
# Set the terminal size initially
|
||||
self._set_terminal_size(80, 24)
|
||||
|
||||
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||
# Using -it because the slave fd IS a TTY
|
||||
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
"-it",
|
||||
"-e",
|
||||
"TERM=xterm",
|
||||
"TERM=xterm-256color",
|
||||
self.container_id,
|
||||
"bash",
|
||||
"-il",
|
||||
@@ -45,44 +52,71 @@ class TerminalSession:
|
||||
stdout=self._slave_fd,
|
||||
stderr=self._slave_fd,
|
||||
)
|
||||
|
||||
# Close slave fd in parent process
|
||||
|
||||
os.close(self._slave_fd)
|
||||
self._slave_fd = None
|
||||
self._echo_enabled = self._detect_echo_state()
|
||||
|
||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||
"""Set the terminal size using TIOCSWINSZ."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
# TIOCSWINSZ = 0x5414 on Linux
|
||||
TIOCSWINSZ = 0x5414
|
||||
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||
try:
|
||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
tiocswinsz = 0x5414
|
||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.ioctl(self._master_fd, tiocswinsz, size)
|
||||
|
||||
async def read_output(self) -> bytes:
|
||||
def _detect_echo_state(self) -> bool:
|
||||
"""Detect whether the PTY has echo enabled via termios."""
|
||||
if self._master_fd is None:
|
||||
return True
|
||||
try:
|
||||
attrs = termios.tcgetattr(self._master_fd)
|
||||
return bool(attrs[3] & termios.ECHO)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
async def check_echo_state(self) -> bool | None:
|
||||
"""Check if echo state changed. Returns new state if changed, None otherwise."""
|
||||
current = self._detect_echo_state()
|
||||
if current != self._echo_enabled:
|
||||
self._echo_enabled = current
|
||||
return current
|
||||
return None
|
||||
|
||||
@property
|
||||
def echo_enabled(self) -> bool:
|
||||
"""Return whether the PTY currently has echo enabled."""
|
||||
return self._echo_enabled
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
"""Return whether the session has been closed."""
|
||||
return self._closed
|
||||
|
||||
async def read_output(self, select_timeout: float = 0.1) -> bytes:
|
||||
"""Read output from the PTY master."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return b""
|
||||
try:
|
||||
# Use select to check if data is available
|
||||
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
||||
readable, _, _ = select.select(
|
||||
[self._master_fd],
|
||||
[],
|
||||
[],
|
||||
select_timeout,
|
||||
)
|
||||
if readable:
|
||||
return os.read(self._master_fd, 4096)
|
||||
return os.read(self._master_fd, 8192)
|
||||
return b""
|
||||
except (OSError, IOError, ValueError):
|
||||
except (OSError, ValueError):
|
||||
return b""
|
||||
|
||||
async def write_input(self, data: bytes) -> None:
|
||||
"""Write input to the PTY master."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.write(self._master_fd, data)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
async def resize(self, cols: int, rows: int) -> None:
|
||||
"""Resize the terminal."""
|
||||
@@ -90,24 +124,35 @@ class TerminalSession:
|
||||
return
|
||||
self._set_terminal_size(cols, rows)
|
||||
|
||||
def get_exit_reason(self) -> str | None:
|
||||
"""Return the reason the session ended, if known."""
|
||||
return self._exit_reason
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the session and cleanup."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
|
||||
# Determine exit reason
|
||||
if self.process is not None and self.process.returncode is not None:
|
||||
if self.process.returncode == 0:
|
||||
self._exit_reason = "process_exit"
|
||||
else:
|
||||
self._exit_reason = "process_exit"
|
||||
else:
|
||||
self._exit_reason = "timeout"
|
||||
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(self._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._master_fd = None
|
||||
|
||||
if self.process is not None:
|
||||
try:
|
||||
self.process.kill()
|
||||
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
||||
except (asyncio.TimeoutError, ProcessLookupError):
|
||||
except (TimeoutError, ProcessLookupError):
|
||||
pass
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Unit tests for TerminalManager."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.terminal_manager import TerminalManager
|
||||
from src.services.terminal_session import TerminalSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager():
|
||||
return TerminalManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket():
|
||||
ws = AsyncMock()
|
||||
ws.send_bytes = AsyncMock()
|
||||
ws.send_json = AsyncMock()
|
||||
ws.close = AsyncMock()
|
||||
ws.receive = AsyncMock()
|
||||
return ws
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
session = MagicMock(spec=TerminalSession)
|
||||
session.session_id = "sess-123"
|
||||
session.is_alive.return_value = True
|
||||
session._closed = False
|
||||
session.read_output = AsyncMock(return_value=b"")
|
||||
session.write_input = AsyncMock()
|
||||
session.resize = AsyncMock()
|
||||
session.close = AsyncMock()
|
||||
session.get_exit_reason.return_value = None
|
||||
return session
|
||||
|
||||
|
||||
class TestCreateSession:
|
||||
@patch("src.services.terminal_manager.asyncio.create_task")
|
||||
@patch("src.services.terminal_manager.uuid.uuid4", return_value="sess-123")
|
||||
async def test_create_session_registers_and_starts_loops(
|
||||
self, mock_uuid, mock_create_task, manager, mock_websocket
|
||||
):
|
||||
instance_id = __import__("uuid").uuid4()
|
||||
mock_sess = MagicMock()
|
||||
mock_sess.session_id = "sess-123"
|
||||
mock_sess.is_alive.return_value = True
|
||||
mock_sess._closed = False
|
||||
mock_sess.start = AsyncMock()
|
||||
mock_sess.read_output = AsyncMock(return_value=b"")
|
||||
mock_sess.write_input = AsyncMock()
|
||||
mock_sess.resize = AsyncMock()
|
||||
mock_sess.close = AsyncMock()
|
||||
mock_sess.get_exit_reason.return_value = None
|
||||
|
||||
with (
|
||||
patch.object(manager, "_read_loop", new=AsyncMock()),
|
||||
patch.object(manager, "_write_loop", new=AsyncMock()),
|
||||
patch.object(manager, "_heartbeat_loop", new=AsyncMock()),
|
||||
patch(
|
||||
"src.services.terminal_manager.TerminalSession",
|
||||
return_value=mock_sess,
|
||||
),
|
||||
):
|
||||
session = await manager.create_session(
|
||||
instance_id, "container-abc", mock_websocket
|
||||
)
|
||||
assert session.session_id == "sess-123"
|
||||
assert "sess-123" in manager._sessions
|
||||
assert "sess-123" in manager._last_client_message
|
||||
|
||||
|
||||
class TestHandleControlMessage:
|
||||
async def test_handle_resize(self, manager, mock_session, mock_websocket):
|
||||
ctrl = {"type": "resize", "cols": 120, "rows": 40}
|
||||
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
|
||||
mock_session.resize.assert_awaited_once_with(120, 40)
|
||||
|
||||
async def test_handle_ping(self, manager, mock_session, mock_websocket):
|
||||
ctrl = {"type": "ping", "id": 42}
|
||||
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
|
||||
mock_websocket.send_json.assert_awaited_once_with({"type": "pong", "id": 42})
|
||||
|
||||
async def test_handle_unknown_type(self, manager, mock_session, mock_websocket):
|
||||
ctrl = {"type": "unknown", "data": "test"}
|
||||
await manager._handle_control_message(mock_session, mock_websocket, ctrl)
|
||||
mock_websocket.send_json.assert_not_awaited()
|
||||
mock_session.resize.assert_not_awaited()
|
||||
|
||||
|
||||
class TestCleanupSession:
|
||||
async def test_cleanup_removes_session(self, manager, mock_session):
|
||||
manager._sessions["sess-123"] = mock_session
|
||||
manager._last_client_message["sess-123"] = 123.0
|
||||
|
||||
await manager._cleanup_session(mock_session)
|
||||
assert "sess-123" not in manager._sessions
|
||||
assert "sess-123" not in manager._last_client_message
|
||||
mock_session.close.assert_awaited_once()
|
||||
|
||||
|
||||
class TestCloseAll:
|
||||
async def test_close_all_clears_sessions(self, manager, mock_session):
|
||||
manager._sessions["sess-123"] = mock_session
|
||||
manager._last_client_message["sess-123"] = 123.0
|
||||
|
||||
await manager.close_all()
|
||||
assert len(manager._sessions) == 0
|
||||
assert len(manager._last_client_message) == 0
|
||||
mock_session.close.assert_awaited_once()
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Unit tests for TerminalSession."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.terminal_session import TerminalSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pty():
|
||||
"""Mock pty.openpty to return predictable fds."""
|
||||
master_fd = 10
|
||||
slave_fd = 11
|
||||
with (
|
||||
patch(
|
||||
"src.services.terminal_session.pty.openpty",
|
||||
return_value=(master_fd, slave_fd),
|
||||
),
|
||||
patch("src.services.terminal_session.os.close") as mock_close,
|
||||
):
|
||||
yield master_fd, slave_fd, mock_close
|
||||
|
||||
|
||||
class TestTerminalSessionStart:
|
||||
def test_init_state(self, mock_pty):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
|
||||
assert session.session_id == "sess-1"
|
||||
assert session.container_id == "container-abc"
|
||||
assert session._echo_enabled is True
|
||||
assert session._exit_reason is None
|
||||
|
||||
|
||||
class TestTerminalSessionEchoDetection:
|
||||
@patch("src.services.terminal_session.termios.tcgetattr")
|
||||
def test_detect_echo_state_enabled(self, mock_tcgetattr):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
# termios.ECHO flag set
|
||||
attrs = [[], [], [], __import__("termios").ECHO, [], [], []]
|
||||
mock_tcgetattr.return_value = attrs
|
||||
|
||||
result = session._detect_echo_state()
|
||||
assert result is True
|
||||
|
||||
@patch("src.services.terminal_session.termios.tcgetattr")
|
||||
def test_detect_echo_state_disabled(self, mock_tcgetattr):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
# termios.ECHO flag NOT set
|
||||
attrs = [[], [], [], 0, [], [], []]
|
||||
mock_tcgetattr.return_value = attrs
|
||||
|
||||
result = session._detect_echo_state()
|
||||
assert result is False
|
||||
|
||||
def test_detect_echo_state_no_master_fd(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = None
|
||||
|
||||
result = session._detect_echo_state()
|
||||
assert result is True # default
|
||||
|
||||
|
||||
class TestTerminalSessionResize:
|
||||
@patch("src.services.terminal_session.fcntl.ioctl")
|
||||
def test_resize_sets_size(self, mock_ioctl):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
# Should not raise
|
||||
asyncio.run(session.resize(120, 40))
|
||||
mock_ioctl.assert_called_once()
|
||||
|
||||
def test_resize_when_closed(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._closed = True
|
||||
|
||||
# Should not raise
|
||||
asyncio.run(session.resize(120, 40))
|
||||
|
||||
|
||||
class TestTerminalSessionWriteInput:
|
||||
@patch("src.services.terminal_session.os.write")
|
||||
def test_write_input(self, mock_write):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
asyncio.run(session.write_input(b"hello"))
|
||||
mock_write.assert_called_once_with(10, b"hello")
|
||||
|
||||
def test_write_input_when_closed(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._closed = True
|
||||
|
||||
# Should not raise
|
||||
asyncio.run(session.write_input(b"hello"))
|
||||
|
||||
|
||||
class TestTerminalSessionReadOutput:
|
||||
@patch("src.services.terminal_session.select.select")
|
||||
@patch("src.services.terminal_session.os.read")
|
||||
def test_read_output_with_data(self, mock_read, mock_select):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
mock_select.return_value = ([10], [], [])
|
||||
mock_read.return_value = b"output"
|
||||
|
||||
result = asyncio.run(session.read_output())
|
||||
assert result == b"output"
|
||||
|
||||
@patch("src.services.terminal_session.select.select")
|
||||
def test_read_output_no_data(self, mock_select):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
|
||||
mock_select.return_value = ([], [], [])
|
||||
|
||||
result = asyncio.run(session.read_output())
|
||||
assert result == b""
|
||||
|
||||
|
||||
class TestTerminalSessionClose:
|
||||
@patch("src.services.terminal_session.os.close")
|
||||
@patch("src.services.terminal_session.asyncio.wait_for")
|
||||
async def test_close_sets_exit_reason(self, mock_wait_for, mock_close):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._master_fd = 10
|
||||
session.process = MagicMock()
|
||||
session.process.returncode = 0
|
||||
|
||||
await session.close()
|
||||
assert session._exit_reason == "process_exit"
|
||||
assert session._closed is True
|
||||
|
||||
async def test_close_idempotent(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session._closed = True
|
||||
|
||||
# Should not raise
|
||||
await session.close()
|
||||
|
||||
|
||||
class TestTerminalSessionIsAlive:
|
||||
def test_is_alive_with_running_process(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session.process = MagicMock()
|
||||
session.process.returncode = None
|
||||
|
||||
assert session.is_alive() is True
|
||||
|
||||
def test_is_alive_with_exited_process(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session.process = MagicMock()
|
||||
session.process.returncode = 0
|
||||
|
||||
assert session.is_alive() is False
|
||||
|
||||
def test_is_alive_no_process(self):
|
||||
session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc")
|
||||
session.process = None
|
||||
|
||||
assert session.is_alive() is False
|
||||
Generated
+11
@@ -19,6 +19,7 @@
|
||||
"tailwindcss": "^3.3.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-serialize": "^0.11.0",
|
||||
"xterm-addon-web-links": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -6372,6 +6373,16 @@
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-serialize": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-serialize/-/xterm-addon-serialize-0.11.0.tgz",
|
||||
"integrity": "sha512-2CNDnmLdLkNWfsxNFkGsI5FE9W/BbsMzeOrbu59yNqH9L6k1gmL+Ab6VXxEp2NQUJSzaiqi6t0nFR5k5EDkVIg==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-serialize instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-web-links": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"tailwindcss": "^3.3.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-serialize": "^0.11.0",
|
||||
"xterm-addon-web-links": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,158 +1,274 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Terminal } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import { SerializeAddon } from "xterm-addon-serialize";
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
import "xterm/css/xterm.css";
|
||||
|
||||
import { useTerminalConnection } from "../hooks/use-terminal-connection";
|
||||
import type {
|
||||
ServerControlMessage,
|
||||
TerminalConnectionState,
|
||||
} from "../types/terminal";
|
||||
|
||||
interface TerminalProps {
|
||||
instanceId: string;
|
||||
onClose?: () => void;
|
||||
instanceId: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
|
||||
"connecting",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
// Initialize terminal
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
selectionBackground: "#264f78",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#f14c4c",
|
||||
brightGreen: "#23d18b",
|
||||
brightYellow: "#f5f543",
|
||||
brightBlue: "#3b8eea",
|
||||
brightMagenta: "#d670d6",
|
||||
brightCyan: "#29b8db",
|
||||
brightWhite: "#e5e5e5",
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
term.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
// Build WebSocket URL
|
||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||
|
||||
// Connect WebSocket
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus("connected");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
const data = new Uint8Array(buffer);
|
||||
term.write(data);
|
||||
});
|
||||
} else if (typeof event.data === "string") {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "status" && msg.status === "connected") {
|
||||
setStatus("connected");
|
||||
}
|
||||
} catch {
|
||||
term.write(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setStatus("disconnected");
|
||||
if (event.code !== 1000) {
|
||||
setError(`Connection closed (code: ${event.code})`);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus("error");
|
||||
setError("WebSocket error");
|
||||
};
|
||||
|
||||
// Handle terminal input
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
fitAddon.fit();
|
||||
const { cols, rows } = term;
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols,
|
||||
rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Initial resize
|
||||
setTimeout(handleResize, 100);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
ws.close();
|
||||
term.dispose();
|
||||
};
|
||||
}, [instanceId]);
|
||||
|
||||
return (
|
||||
<div className="terminal-wrapper">
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-status">
|
||||
<span
|
||||
className={`status-dot ${status}`}
|
||||
aria-label={`Terminal status: ${status}`}
|
||||
/>
|
||||
<span className="status-text">{status}</span>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button className="terminal-close" onClick={onClose} type="button">
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="terminal-error">{error}</div>}
|
||||
<div ref={terminalRef} className="terminal-container" />
|
||||
</div>
|
||||
);
|
||||
const STATUS_DOT_COLORS: Record<TerminalConnectionState["status"], string> = {
|
||||
connecting: "var(--warning)",
|
||||
connected: "var(--success)",
|
||||
reconnecting: "var(--warning)",
|
||||
disconnected: "var(--muted)",
|
||||
};
|
||||
|
||||
function getStatusText(state: TerminalConnectionState): string {
|
||||
switch (state.status) {
|
||||
case "connecting":
|
||||
return "Connecting...";
|
||||
case "connected": {
|
||||
if (state.latency !== null && state.latency >= 100) {
|
||||
return `Slow (${state.latency}ms)`;
|
||||
}
|
||||
return "Connected";
|
||||
}
|
||||
case "reconnecting":
|
||||
return `Reconnecting${state.attempt > 0 ? ` (${state.attempt})` : ""}`;
|
||||
case "disconnected":
|
||||
return state.error || "Disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
instanceId,
|
||||
onClose,
|
||||
}) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const [sessionEnded, setSessionEnded] = useState<{
|
||||
reason: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// Determine dark mode from document theme
|
||||
const isDarkMode =
|
||||
document.documentElement.getAttribute("data-theme") === "dark" ||
|
||||
(document.documentElement.getAttribute("data-theme") === null &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
const handleData = useCallback((data: Uint8Array) => {
|
||||
// Data is already written by onLocalEcho or deduplication
|
||||
// This callback is mainly for external consumers
|
||||
void data;
|
||||
}, []);
|
||||
|
||||
const handleLocalEcho = useCallback((data: string) => {
|
||||
xtermRef.current?.write(data);
|
||||
}, []);
|
||||
|
||||
const serializeFn = useCallback((): string | null => {
|
||||
return serializeAddonRef.current?.serialize() ?? null;
|
||||
}, []);
|
||||
|
||||
const handleRestoreScrollback = useCallback((content: string) => {
|
||||
xtermRef.current?.write(content);
|
||||
xtermRef.current?.write("\r\n\x1b[90m--- Reconnected ---\x1b[0m\r\n");
|
||||
}, []);
|
||||
|
||||
const handleControl = useCallback((msg: ServerControlMessage) => {
|
||||
if (msg.type === "session_ended") {
|
||||
const messages: Record<string, string> = {
|
||||
process_exit: "The container process has exited.",
|
||||
container_stop: "The container was stopped.",
|
||||
timeout: "The session timed out due to inactivity.",
|
||||
};
|
||||
setSessionEnded({
|
||||
reason: msg.reason,
|
||||
message: messages[msg.reason] || "The session has ended.",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { state, sendInput, sendResize, reconnect } = useTerminalConnection({
|
||||
instanceId,
|
||||
onData: handleData,
|
||||
onControl: handleControl,
|
||||
onLocalEcho: handleLocalEcho,
|
||||
serializeFn,
|
||||
onRestoreScrollback: handleRestoreScrollback,
|
||||
});
|
||||
|
||||
// Initialize xterm
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: isDarkMode
|
||||
? {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
selectionBackground: "#264f78",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#f14c4c",
|
||||
brightGreen: "#23d18b",
|
||||
brightYellow: "#f5f543",
|
||||
brightBlue: "#3b8eea",
|
||||
brightMagenta: "#d670d6",
|
||||
brightCyan: "#29b8db",
|
||||
brightWhite: "#e5e5e5",
|
||||
}
|
||||
: {
|
||||
background: "#fafafa",
|
||||
foreground: "#333333",
|
||||
cursor: "#333333",
|
||||
selectionBackground: "#b4d7ff",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#f14c4c",
|
||||
brightGreen: "#23d18b",
|
||||
brightYellow: "#f5f543",
|
||||
brightBlue: "#3b8eea",
|
||||
brightMagenta: "#d670d6",
|
||||
brightCyan: "#29b8db",
|
||||
brightWhite: "#e5e5e5",
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const serializeAddon = new SerializeAddon();
|
||||
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(serializeAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
term.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
serializeAddonRef.current = serializeAddon;
|
||||
|
||||
// Handle terminal input
|
||||
const disposable = term.onData((data) => {
|
||||
sendInput(data);
|
||||
});
|
||||
|
||||
// Resize observer for container-level resize detection
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
const { cols, rows } = term;
|
||||
sendResize(cols, rows);
|
||||
});
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
resizeObserver.disconnect();
|
||||
term.dispose();
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
serializeAddonRef.current = null;
|
||||
};
|
||||
}, [instanceId, isDarkMode, sendInput, sendResize]);
|
||||
|
||||
return (
|
||||
<div className="terminal-wrapper">
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-status">
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{
|
||||
backgroundColor: STATUS_DOT_COLORS[state.status],
|
||||
}}
|
||||
aria-label={`Terminal status: ${state.status}`}
|
||||
title={
|
||||
state.latency !== null
|
||||
? `Latency: ${state.latency}ms`
|
||||
: getStatusText(state)
|
||||
}
|
||||
/>
|
||||
<span className="status-text">{getStatusText(state)}</span>
|
||||
</div>
|
||||
<div className="terminal-actions">
|
||||
{state.status === "disconnected" && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={reconnect}
|
||||
type="button"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button className="terminal-close" onClick={onClose} type="button">
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionEnded && (
|
||||
<div className="terminal-overlay">
|
||||
<div className="terminal-overlay-content">
|
||||
<h3>Session Ended</h3>
|
||||
<p>{sessionEnded.message}</p>
|
||||
<div className="terminal-overlay-actions">
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() => {
|
||||
setSessionEnded(null);
|
||||
reconnect();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "reconnecting" && (
|
||||
<div className="terminal-reconnect-banner">
|
||||
<span className="spinner" />
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={terminalRef} className="terminal-container" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTerminalConnection } from "./use-terminal-connection";
|
||||
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = [];
|
||||
readyState: number = WebSocket.CONNECTING;
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
sent: (string | ArrayBuffer | Blob)[] = [];
|
||||
url = "";
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(data: string | ArrayBuffer | Blob) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
if (this.onclose) {
|
||||
this.onclose(new CloseEvent("close", { code: code ?? 1000, reason }));
|
||||
}
|
||||
}
|
||||
|
||||
simulateOpen() {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
if (this.onopen) this.onopen(new Event("open"));
|
||||
}
|
||||
|
||||
simulateMessage(data: string | ArrayBuffer | Blob) {
|
||||
if (this.onmessage) {
|
||||
this.onmessage(new MessageEvent("message", { data }));
|
||||
}
|
||||
}
|
||||
|
||||
simulateError() {
|
||||
if (this.onerror) this.onerror(new Event("error"));
|
||||
}
|
||||
}
|
||||
|
||||
describe("useTerminalConnection", () => {
|
||||
let originalWebSocket: typeof WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
originalWebSocket = globalThis.WebSocket;
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
MockWebSocket.instances = [];
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("import", { meta: { env: { VITE_API_BASE_URL: "" } } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
MockWebSocket.instances = [];
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("starts in connecting state", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
expect(result.current.state.status).toBe("connecting");
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("transitions to connected on websocket open", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("connected");
|
||||
});
|
||||
|
||||
it("sends ping after interval", async () => {
|
||||
renderHook(() => useTerminalConnection({ instanceId: "inst-1" }));
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
|
||||
const pings = MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("ping") : false,
|
||||
);
|
||||
expect(pings.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("handles pong and updates latency", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateMessage(
|
||||
JSON.stringify({ type: "pong", id: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.state.latency).not.toBeNull();
|
||||
expect(result.current.state.latency).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("reconnects with exponential backoff on close", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].close(1006, "Abnormal closure");
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("reconnecting");
|
||||
expect(result.current.state.attempt).toBe(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("max reconnect attempts leads to disconnected", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||
act(() => {
|
||||
ws.close(1006, "Abnormal closure");
|
||||
});
|
||||
const delay = Math.min(1000 * 2 ** i, 30000);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(delay);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.current.state.status).toBe("disconnected");
|
||||
expect(result.current.state.error).toContain("Max reconnection");
|
||||
}, 30000);
|
||||
|
||||
it("sends resize message with debounce", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendResize(120, 40);
|
||||
});
|
||||
|
||||
// Before debounce
|
||||
expect(
|
||||
MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("resize") : false,
|
||||
),
|
||||
).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
const resizes = MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("resize") : false,
|
||||
);
|
||||
expect(resizes.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("throttles resize messages", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendResize(100, 30);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendResize(101, 31);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
const resizes = MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("resize") : false,
|
||||
);
|
||||
// Second resize throttled (within 500ms)
|
||||
expect(resizes.length).toBe(1);
|
||||
});
|
||||
|
||||
it("sendInput sends data over websocket", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendInput("a");
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances[0].sent).toContain("a");
|
||||
});
|
||||
|
||||
it("triggers manual reconnect on reconnect()", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.reconnect();
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("calls onData callback with binary data", async () => {
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useTerminalConnection({ instanceId: "inst-1", onData }));
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
const buffer = new ArrayBuffer(3);
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateMessage(buffer);
|
||||
});
|
||||
|
||||
expect(onData).toHaveBeenCalledWith(expect.any(Uint8Array));
|
||||
});
|
||||
|
||||
it("calls onControl callback with control messages", async () => {
|
||||
const onControl = vi.fn();
|
||||
renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1", onControl }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateMessage(
|
||||
JSON.stringify({ type: "set_echo_state", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onControl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "set_echo_state", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes and restores scrollback", async () => {
|
||||
const serializeFn = vi.fn(() => "scrollback-content");
|
||||
const onRestoreScrollback = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useTerminalConnection({
|
||||
instanceId: "inst-1",
|
||||
serializeFn,
|
||||
onRestoreScrollback,
|
||||
}),
|
||||
{ initialProps: {} },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].close(1006, "gone");
|
||||
});
|
||||
|
||||
expect(serializeFn).toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// New connection opens
|
||||
act(() => {
|
||||
MockWebSocket.instances[
|
||||
MockWebSocket.instances.length - 1
|
||||
].simulateOpen();
|
||||
});
|
||||
|
||||
expect(onRestoreScrollback).toHaveBeenCalledWith("scrollback-content");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
ClientControlMessage,
|
||||
ServerControlMessage,
|
||||
TerminalConnectionState,
|
||||
} from "../types/terminal";
|
||||
import {
|
||||
decodeControlMessage,
|
||||
encodeControlMessage,
|
||||
isControlMessage,
|
||||
} from "../utils/terminal-protocol";
|
||||
|
||||
const PING_INTERVAL_MS = 15_000;
|
||||
const PONG_TIMEOUT_MS = 5_000;
|
||||
const RECONNECT_BASE_MS = 1_000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
const MAX_RECONNECT_ATTEMPTS = 10;
|
||||
const RESIZE_DEBOUNCE_MS = 200;
|
||||
const RESIZE_THROTTLE_MS = 500;
|
||||
const PENDING_ECHO_FLUSH_LIMIT = 100;
|
||||
const SCROLLBACK_STORAGE_KEY = "hq-terminal";
|
||||
|
||||
interface UseTerminalConnectionOptions {
|
||||
instanceId: string;
|
||||
onData?: (data: Uint8Array) => void;
|
||||
onControl?: (msg: ServerControlMessage) => void;
|
||||
/** Called with characters that should be locally echoed. */
|
||||
onLocalEcho?: (data: string) => void;
|
||||
/** Called to serialize scrollback before disconnect. Should return terminal content. */
|
||||
serializeFn?: () => string | null;
|
||||
/** Called with restored scrollback content on reconnect. */
|
||||
onRestoreScrollback?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function useTerminalConnection({
|
||||
instanceId,
|
||||
onData,
|
||||
onControl,
|
||||
onLocalEcho,
|
||||
serializeFn,
|
||||
onRestoreScrollback,
|
||||
}: UseTerminalConnectionOptions) {
|
||||
const [state, setState] = useState<TerminalConnectionState>({
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
latency: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pongTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastResizeRef = useRef<number>(0);
|
||||
const pendingEchoRef = useRef<string>("");
|
||||
const echoEnabledRef = useRef<boolean>(true);
|
||||
const pingIdRef = useRef<number>(0);
|
||||
const pingSentAtRef = useRef<number>(0);
|
||||
const reconnectAttemptRef = useRef<number>(0);
|
||||
const isConnectingRef = useRef<boolean>(false);
|
||||
const lastStatusRef = useRef<string>("connecting");
|
||||
|
||||
const setStableState = useCallback(
|
||||
(updater: (prev: TerminalConnectionState) => TerminalConnectionState) => {
|
||||
setState((prev) => {
|
||||
const next = updater(prev);
|
||||
if (next.status !== lastStatusRef.current) {
|
||||
lastStatusRef.current = next.status;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (pingTimerRef.current) {
|
||||
clearTimeout(pingTimerRef.current);
|
||||
pingTimerRef.current = null;
|
||||
}
|
||||
if (pongTimerRef.current) {
|
||||
clearTimeout(pongTimerRef.current);
|
||||
pongTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flushPendingEcho = useCallback(() => {
|
||||
if (pendingEchoRef.current.length > 0 && onLocalEcho) {
|
||||
onLocalEcho(pendingEchoRef.current);
|
||||
pendingEchoRef.current = "";
|
||||
}
|
||||
}, [onLocalEcho]);
|
||||
|
||||
const deduplicateServerData = useCallback((data: string): string => {
|
||||
if (!echoEnabledRef.current || pendingEchoRef.current.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let serverIndex = 0;
|
||||
let echoIndex = 0;
|
||||
|
||||
while (
|
||||
serverIndex < data.length &&
|
||||
echoIndex < pendingEchoRef.current.length &&
|
||||
data[serverIndex] === pendingEchoRef.current[echoIndex]
|
||||
) {
|
||||
serverIndex++;
|
||||
echoIndex++;
|
||||
}
|
||||
|
||||
if (echoIndex > 0) {
|
||||
pendingEchoRef.current = pendingEchoRef.current.slice(echoIndex);
|
||||
}
|
||||
|
||||
return data.slice(serverIndex);
|
||||
}, []);
|
||||
|
||||
const handleBinaryMessage = useCallback(
|
||||
(buffer: ArrayBuffer) => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
|
||||
if (onData) {
|
||||
onData(bytes);
|
||||
}
|
||||
|
||||
// Deduplicate local echo if active
|
||||
if (echoEnabledRef.current && pendingEchoRef.current.length > 0) {
|
||||
const remaining = deduplicateServerData(text);
|
||||
if (remaining.length > 0 && onLocalEcho) {
|
||||
onLocalEcho(remaining);
|
||||
}
|
||||
} else if (onLocalEcho) {
|
||||
onLocalEcho(text);
|
||||
}
|
||||
|
||||
// Flush stale pending echo buffer
|
||||
if (pendingEchoRef.current.length > PENDING_ECHO_FLUSH_LIMIT) {
|
||||
flushPendingEcho();
|
||||
}
|
||||
},
|
||||
[onData, onLocalEcho, deduplicateServerData, flushPendingEcho],
|
||||
);
|
||||
|
||||
const handleControlMessage = useCallback(
|
||||
(msg: ServerControlMessage) => {
|
||||
if (onControl) {
|
||||
onControl(msg);
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "pong": {
|
||||
const elapsed = Date.now() - pingSentAtRef.current;
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
latency: elapsed,
|
||||
status: prev.status === "reconnecting" ? "connected" : prev.status,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "status": {
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "connected",
|
||||
attempt: 0,
|
||||
error: null,
|
||||
}));
|
||||
reconnectAttemptRef.current = 0;
|
||||
break;
|
||||
}
|
||||
case "set_echo_state": {
|
||||
echoEnabledRef.current = msg.enabled;
|
||||
if (!msg.enabled) {
|
||||
// Server disabled echo — flush any pending local echo
|
||||
flushPendingEcho();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "session_ended": {
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "disconnected",
|
||||
error: `Session ended: ${msg.reason}`,
|
||||
}));
|
||||
clearTimers();
|
||||
wsRef.current?.close(1000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[onControl, setStableState, clearTimers, flushPendingEcho],
|
||||
);
|
||||
|
||||
const schedulePing = useCallback(() => {
|
||||
pingTimerRef.current = setTimeout(() => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const id = ++pingIdRef.current;
|
||||
pingSentAtRef.current = Date.now();
|
||||
const pingMsg: ClientControlMessage = { type: "ping", id };
|
||||
ws.send(encodeControlMessage(pingMsg));
|
||||
|
||||
// Set pong timeout
|
||||
pongTimerRef.current = setTimeout(() => {
|
||||
// Pong not received — connection is dead
|
||||
ws.close(1001, "Ping timeout");
|
||||
}, PONG_TIMEOUT_MS);
|
||||
}, PING_INTERVAL_MS);
|
||||
}, []);
|
||||
|
||||
const serializeScrollback = useCallback(() => {
|
||||
if (!serializeFn) return;
|
||||
try {
|
||||
const serialized = serializeFn();
|
||||
if (serialized) {
|
||||
sessionStorage.setItem(
|
||||
`${SCROLLBACK_STORAGE_KEY}-${instanceId}`,
|
||||
serialized,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore serialization errors
|
||||
}
|
||||
}, [serializeFn, instanceId]);
|
||||
|
||||
const restoreScrollback = useCallback(() => {
|
||||
if (!onRestoreScrollback) return;
|
||||
try {
|
||||
const key = `${SCROLLBACK_STORAGE_KEY}-${instanceId}`;
|
||||
const serialized = sessionStorage.getItem(key);
|
||||
if (serialized) {
|
||||
onRestoreScrollback(serialized);
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Ignore restoration errors
|
||||
}
|
||||
}, [onRestoreScrollback, instanceId]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (isConnectingRef.current) return;
|
||||
isConnectingRef.current = true;
|
||||
|
||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
isConnectingRef.current = false;
|
||||
reconnectAttemptRef.current = 0;
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "connected",
|
||||
attempt: 0,
|
||||
error: null,
|
||||
}));
|
||||
restoreScrollback();
|
||||
schedulePing();
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
if (isControlMessage(event)) {
|
||||
const msg = decodeControlMessage(event.data as string);
|
||||
if (msg) {
|
||||
handleControlMessage(msg);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
handleBinaryMessage(event.data);
|
||||
} else if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
handleBinaryMessage(buffer);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
wsRef.current = null;
|
||||
clearTimers();
|
||||
|
||||
if (event.code === 1000 || event.code === 1001) {
|
||||
// Normal or going-away close
|
||||
setStableState(() => ({
|
||||
status: "disconnected",
|
||||
attempt: 0,
|
||||
latency: null,
|
||||
error: event.reason || null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Unexpected close — attempt reconnect
|
||||
const attempt = ++reconnectAttemptRef.current;
|
||||
if (attempt > MAX_RECONNECT_ATTEMPTS) {
|
||||
setStableState(() => ({
|
||||
status: "disconnected",
|
||||
attempt,
|
||||
latency: null,
|
||||
error: "Max reconnection attempts exceeded",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
serializeScrollback();
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_MS * 2 ** (attempt - 1),
|
||||
RECONNECT_MAX_MS,
|
||||
);
|
||||
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "reconnecting",
|
||||
attempt,
|
||||
error: `Reconnecting in ${Math.round(delay / 1000)}s...`,
|
||||
}));
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
isConnectingRef.current = false;
|
||||
// Let onclose handle reconnection
|
||||
};
|
||||
}, [
|
||||
instanceId,
|
||||
setStableState,
|
||||
clearTimers,
|
||||
schedulePing,
|
||||
handleBinaryMessage,
|
||||
handleControlMessage,
|
||||
serializeScrollback,
|
||||
restoreScrollback,
|
||||
]);
|
||||
|
||||
const sendInput = useCallback(
|
||||
(data: string) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// Local echo for printable ASCII characters
|
||||
if (
|
||||
echoEnabledRef.current &&
|
||||
data.length === 1 &&
|
||||
data.charCodeAt(0) >= 32 &&
|
||||
data.charCodeAt(0) <= 126
|
||||
) {
|
||||
pendingEchoRef.current += data;
|
||||
if (onLocalEcho) {
|
||||
onLocalEcho(data);
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(data);
|
||||
},
|
||||
[onLocalEcho],
|
||||
);
|
||||
|
||||
const sendResize = useCallback((cols: number, rows: number) => {
|
||||
if (resizeTimerRef.current) {
|
||||
clearTimeout(resizeTimerRef.current);
|
||||
}
|
||||
|
||||
resizeTimerRef.current = setTimeout(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastResizeRef.current < RESIZE_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
lastResizeRef.current = now;
|
||||
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const msg: ClientControlMessage = { type: "resize", cols, rows };
|
||||
ws.send(encodeControlMessage(msg));
|
||||
}, RESIZE_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
clearTimers();
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, "Manual reconnect");
|
||||
wsRef.current = null;
|
||||
}
|
||||
reconnectAttemptRef.current = 0;
|
||||
setStableState(() => ({
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
latency: null,
|
||||
error: null,
|
||||
}));
|
||||
connect();
|
||||
}, [clearTimers, connect, setStableState]);
|
||||
|
||||
// Initial connection
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
clearTimers();
|
||||
if (resizeTimerRef.current) {
|
||||
clearTimeout(resizeTimerRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, "Component unmount");
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [instanceId, connect, clearTimers]);
|
||||
|
||||
// Keyboard shortcut for manual reconnect
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === "R") {
|
||||
e.preventDefault();
|
||||
reconnect();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [reconnect]);
|
||||
|
||||
return {
|
||||
state,
|
||||
sendInput,
|
||||
sendResize,
|
||||
reconnect,
|
||||
};
|
||||
}
|
||||
+164
-131
@@ -2479,137 +2479,6 @@ a.nav-item,
|
||||
Terminal Styles
|
||||
============================================ */
|
||||
|
||||
.terminal-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.terminal-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #3e3e3e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background: #f5f543;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #0dbc79;
|
||||
}
|
||||
|
||||
.status-dot.disconnected,
|
||||
.status-dot.error {
|
||||
background: #cd3131;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.875rem;
|
||||
color: #d4d4d4;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.terminal-close {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: transparent;
|
||||
border: 1px solid #666;
|
||||
border-radius: 6px;
|
||||
color: #d4d4d4;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.terminal-close:hover {
|
||||
background: #3e3e3e;
|
||||
}
|
||||
|
||||
.terminal-error {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: #cd3131;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.terminal-container .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport {
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
|
||||
/* Responsive terminal */
|
||||
@media (max-width: 767px) {
|
||||
.terminal-page {
|
||||
padding: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.terminal-page-header h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Sessions Page Styles
|
||||
============================================ */
|
||||
@@ -2805,3 +2674,167 @@ a.nav-item,
|
||||
background: var(--danger-light, #fee2e2);
|
||||
color: var(--danger, #dc2626);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive Terminal — Updated
|
||||
============================================ */
|
||||
|
||||
.terminal-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #3e3e3e;
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-status .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-status .status-text {
|
||||
font-size: 0.8rem;
|
||||
color: #d4d4d4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.terminal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-close {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
border: 1px solid #666;
|
||||
border-radius: 6px;
|
||||
color: #d4d4d4;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.terminal-close:hover {
|
||||
background: #3e3e3e;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.terminal-container .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport {
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
|
||||
/* Terminal overlay for session ended */
|
||||
.terminal-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.terminal-overlay-content {
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #3e3e3e;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.terminal-overlay-content h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #f14c4c;
|
||||
}
|
||||
|
||||
.terminal-overlay-content p {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.terminal-overlay-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Reconnect banner */
|
||||
.terminal-reconnect-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: #3e3e3e;
|
||||
color: #f5f543;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive terminal */
|
||||
@media (max-width: 767px) {
|
||||
.terminal-page {
|
||||
padding: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.terminal-page-header h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.terminal-overlay-content {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* WebSocket protocol types for the responsive terminal.
|
||||
*
|
||||
* Binary frames carry raw terminal I/O.
|
||||
* Text (JSON) frames carry control messages.
|
||||
*/
|
||||
|
||||
// ── Client → Server ──
|
||||
|
||||
export interface PingMessage {
|
||||
type: "ping";
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface PongMessage {
|
||||
type: "pong";
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface ResizeMessage {
|
||||
type: "resize";
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface InputMessage {
|
||||
type: "input";
|
||||
data: string; // base64-encoded bytes
|
||||
}
|
||||
|
||||
export type ClientControlMessage =
|
||||
| PingMessage
|
||||
| PongMessage
|
||||
| ResizeMessage
|
||||
| InputMessage;
|
||||
|
||||
// ── Server → Client ──
|
||||
|
||||
export interface ServerPongMessage {
|
||||
type: "pong";
|
||||
id: number;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = "connected" | "reconnected";
|
||||
|
||||
export interface StatusMessage {
|
||||
type: "status";
|
||||
status: ConnectionStatus;
|
||||
}
|
||||
|
||||
export interface SetEchoStateMessage {
|
||||
type: "set_echo_state";
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type SessionEndReason = "process_exit" | "container_stop" | "timeout";
|
||||
|
||||
export interface SessionEndedMessage {
|
||||
type: "session_ended";
|
||||
reason: SessionEndReason;
|
||||
}
|
||||
|
||||
export type ServerControlMessage =
|
||||
| ServerPongMessage
|
||||
| StatusMessage
|
||||
| SetEchoStateMessage
|
||||
| SessionEndedMessage;
|
||||
|
||||
// ── Connection state ──
|
||||
|
||||
export type TerminalConnectionStatus =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected";
|
||||
|
||||
export interface TerminalConnectionState {
|
||||
status: TerminalConnectionStatus;
|
||||
attempt: number;
|
||||
latency: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
ClientControlMessage,
|
||||
ServerControlMessage,
|
||||
} from "../types/terminal";
|
||||
|
||||
/**
|
||||
* Encode a client control message to a JSON string for sending over WebSocket.
|
||||
*/
|
||||
export function encodeControlMessage(msg: ClientControlMessage): string {
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a server control message from a JSON string.
|
||||
* Returns null if the data is not valid JSON or not a recognized control message.
|
||||
*/
|
||||
export function decodeControlMessage(
|
||||
data: string,
|
||||
): ServerControlMessage | null {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as unknown;
|
||||
if (!isServerControlMessage(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a WebSocket message is a control message (JSON text frame)
|
||||
* or raw binary data.
|
||||
*/
|
||||
export function isControlMessage(event: MessageEvent): boolean {
|
||||
return typeof event.data === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode raw input bytes to a base64 string for the `input` control message.
|
||||
*/
|
||||
export function encodeInputData(data: string): string {
|
||||
return btoa(unescape(encodeURIComponent(data)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 input data back to a string.
|
||||
*/
|
||||
export function decodeInputData(data: string): string {
|
||||
return decodeURIComponent(escape(atob(data)));
|
||||
}
|
||||
|
||||
// ── Type guards ──
|
||||
|
||||
function isServerControlMessage(value: unknown): value is ServerControlMessage {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj.type !== "string") return false;
|
||||
|
||||
switch (obj.type) {
|
||||
case "pong":
|
||||
return typeof obj.id === "number";
|
||||
case "status":
|
||||
return obj.status === "connected" || obj.status === "reconnected";
|
||||
case "set_echo_state":
|
||||
return typeof obj.enabled === "boolean";
|
||||
case "session_ended":
|
||||
return (
|
||||
obj.reason === "process_exit" ||
|
||||
obj.reason === "container_stop" ||
|
||||
obj.reason === "timeout"
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user