diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py index 8dfc4db..7f11773 100644 --- a/apps/api/src/api/terminal.py +++ b/apps/api/src/api/terminal.py @@ -9,6 +9,7 @@ from contextlib import suppress from fastapi import APIRouter, Depends, HTTPException, WebSocket, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from starlette.websockets import WebSocketDisconnect from src.auth.dependencies import get_current_user_id, get_db_session from src.models.terminal_session import TerminalSessionModel @@ -152,13 +153,36 @@ async def _handle_terminal_websocket( target_session_id, ) if session is None: - logger.warning( - "Session %s not found for instance %s", - target_session_id, - instance_id, + # Session not in memory — may have been lost on server restart. + # Try to restore from the DB row. + db_row = await db_session.get( + TerminalSessionModel, uuid.UUID(target_session_id) ) - await websocket.close(code=4004, reason="Session not found") - return + if ( + db_row is not None + and db_row.instance_id == instance_uuid + and db_row.status != "closed" + ): + logger.info( + "Restoring terminal session %s for instance %s from DB", + target_session_id, + instance_id, + ) + session = await terminal_manager.create_session( + instance_uuid, + instance.container_id, + startup_command=startup_command, + name=db_row.name, + session_id=target_session_id, + ) + else: + logger.warning( + "Session %s not found for instance %s", + target_session_id, + instance_id, + ) + await websocket.close(code=4004, reason="Session not found") + return # Determine slot key for reset scoping key = terminal_manager._find_key_by_internal_id( instance_id, session.session_id @@ -207,6 +231,8 @@ async def _handle_terminal_websocket( for task in pending: task.cancel() + except WebSocketDisconnect: + logger.debug("WebSocket disconnected for instance %s", instance_id) except Exception as exc: logger.error( "Terminal session error for instance %s: %s", @@ -214,7 +240,8 @@ async def _handle_terminal_websocket( str(exc), exc_info=True, ) - await websocket.close(code=4000, reason=f"Error: {exc}") + with suppress(Exception): + await websocket.close(code=4000, reason=f"Error: {exc}") finally: # Detach WebSocket, don't kill session with suppress(Exception): @@ -237,6 +264,8 @@ async def _read_loop(session_ref: SessionRef, websocket) -> None: if data: try: await websocket.send_bytes(data) + except WebSocketDisconnect: + break except Exception: break else: @@ -403,7 +432,9 @@ async def list_terminal_sessions( ) db_rows = result.scalars().all() - # Build response with live has_websockets flag + # Build response with live has_websockets flag. + # Include DB rows even without in-memory counterparts (e.g. after + # server restart) so the frontend can display tabs and reconnect. sessions = [] for row in db_rows: live_session = terminal_manager.get_session(str(instance_id), str(row.id)) diff --git a/apps/api/src/services/terminal_manager.py b/apps/api/src/services/terminal_manager.py index 5ee12a4..dfe7ff5 100644 --- a/apps/api/src/services/terminal_manager.py +++ b/apps/api/src/services/terminal_manager.py @@ -131,6 +131,7 @@ class TerminalManager: container_id: str, startup_command: str | None = None, name: str | None = None, + session_id: str | None = None, ) -> TerminalSession: """Create a new terminal session for an instance. @@ -159,7 +160,8 @@ class TerminalManager: instance_id_str, self.MAX_SESSIONS_PER_INSTANCE ) - session_id = str(uuid.uuid4()) + if session_id is None: + session_id = str(uuid.uuid4()) session = TerminalSession( session_id=session_id, instance_id=instance_id, @@ -172,7 +174,7 @@ class TerminalManager: key = (instance_id_str, session_id) self._sessions[key] = session - # Fire-and-forget DB insert + # Fire-and-forget DB insert (skip if row already exists) asyncio.create_task( self._insert_db_session_row(session_id, instance_id, session.name) ) diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index 5588328..ab2a82b 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -37,6 +37,7 @@ export interface TerminalProps { export interface TerminalRef { fit: () => void; + focus: () => void; } const FONT_SIZE_KEY = "terminal-font-size"; @@ -106,6 +107,7 @@ export const TerminalComponent = React.forwardRef( wsRef.current = ws; ws.onopen = () => { + console.log(`[Terminal ${sessionId ?? "default"}] WebSocket opened`); setStatus("connected"); setError(null); reconnectAttemptsRef.current = 0; @@ -137,6 +139,8 @@ export const TerminalComponent = React.forwardRef( if (!termRef.current) return; if (event.data instanceof Blob) { + // eslint-disable-next-line no-console + console.log(`[Terminal ${sessionId ?? "default"}] received ${(event.data as Blob).size} bytes`); event.data.arrayBuffer().then((buffer) => { const data = new Uint8Array(buffer); termRef.current?.write(data); @@ -306,6 +310,8 @@ export const TerminalComponent = React.forwardRef( // Open xterm first (must happen before fit) term.open(container); + term.focus(); + console.log(`[Terminal ${sessionId ?? "default"}] xterm opened and focused`); const ws = connectWebSocket(); // Initial fit after layout settles (terminal must be opened first) @@ -330,6 +336,8 @@ export const TerminalComponent = React.forwardRef( // Handle terminal input term.onData((data) => { + // eslint-disable-next-line no-console + console.log(`[Terminal ${sessionId ?? "default"}] sending:`, JSON.stringify(data)); const currentWs = wsRef.current; if (currentWs?.readyState !== WebSocket.OPEN) return; @@ -466,6 +474,9 @@ export const TerminalComponent = React.forwardRef( } } }, + focus: () => { + termRef.current?.focus(); + }, })); // Update parent about status changes diff --git a/apps/web/src/hooks/use-terminal-sessions.ts b/apps/web/src/hooks/use-terminal-sessions.ts index e05f527..a995b37 100644 --- a/apps/web/src/hooks/use-terminal-sessions.ts +++ b/apps/web/src/hooks/use-terminal-sessions.ts @@ -25,7 +25,7 @@ export function useTerminalSessions( ): UseTerminalSessionsResult { const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const loadSessions = useCallback(async () => { diff --git a/apps/web/src/pages/terminal.tsx b/apps/web/src/pages/terminal.tsx index 95837f4..74ea37d 100644 --- a/apps/web/src/pages/terminal.tsx +++ b/apps/web/src/pages/terminal.tsx @@ -39,7 +39,7 @@ export const TerminalPage: React.FC = () => { error, } = useTerminalSessions(instanceId ?? ""); - // Auto-create default session if none exist + // Auto-create default session if none exist after loading completes useEffect(() => { if (!loading && sessions.length === 0 && !error && instanceId) { void createSession("Session 1"); @@ -62,13 +62,14 @@ export const TerminalPage: React.FC = () => { } }, [sessions]); - // Fit active terminal when switching tabs + // Fit and focus active terminal when switching tabs useEffect(() => { if (activeSessionId && terminalRefs.current[activeSessionId]) { const ref = terminalRefs.current[activeSessionId]; // Small delay to allow display:block to apply const timer = setTimeout(() => { ref.current?.fit(); + ref.current?.focus(); }, 50); return () => clearTimeout(timer); } @@ -229,23 +230,19 @@ export const TerminalPage: React.FC = () => {
{error &&
{error}
} - {sessions.map((session) => ( -
- handleClose(session.id)} - isMobile={true} - /> -
- ))} + {sessions + .filter((session) => session.id === activeSessionId) + .map((session) => ( +
+ handleClose(session.id)} + isMobile={true} + /> +
+ ))} {sessions.length === 0 && !loading && (

No terminal sessions. Press Alt+Shift+N to create one.

@@ -289,23 +286,19 @@ export const TerminalPage: React.FC = () => { />
{error &&
{error}
} - {sessions.map((session) => ( -
- handleClose(session.id)} - isMobile={false} - /> -
- ))} + {sessions + .filter((session) => session.id === activeSessionId) + .map((session) => ( +
+ handleClose(session.id)} + isMobile={false} + /> +
+ ))} {sessions.length === 0 && !loading && (

No terminal sessions. Press Alt+Shift+N to create one.