Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
Alex Blank
2026-05-28 20:36:39 +02:00
5 changed files with 84 additions and 47 deletions
+39 -8
View File
@@ -9,6 +9,7 @@ from contextlib import suppress
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.auth.dependencies import get_current_user_id, get_db_session
from src.models.terminal_session import TerminalSessionModel from src.models.terminal_session import TerminalSessionModel
@@ -152,13 +153,36 @@ async def _handle_terminal_websocket(
target_session_id, target_session_id,
) )
if session is None: if session is None:
logger.warning( # Session not in memory — may have been lost on server restart.
"Session %s not found for instance %s", # Try to restore from the DB row.
target_session_id, db_row = await db_session.get(
instance_id, TerminalSessionModel, uuid.UUID(target_session_id)
) )
await websocket.close(code=4004, reason="Session not found") if (
return 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 # Determine slot key for reset scoping
key = terminal_manager._find_key_by_internal_id( key = terminal_manager._find_key_by_internal_id(
instance_id, session.session_id instance_id, session.session_id
@@ -207,6 +231,8 @@ async def _handle_terminal_websocket(
for task in pending: for task in pending:
task.cancel() task.cancel()
except WebSocketDisconnect:
logger.debug("WebSocket disconnected for instance %s", instance_id)
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"Terminal session error for instance %s: %s", "Terminal session error for instance %s: %s",
@@ -214,7 +240,8 @@ async def _handle_terminal_websocket(
str(exc), str(exc),
exc_info=True, 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: finally:
# Detach WebSocket, don't kill session # Detach WebSocket, don't kill session
with suppress(Exception): with suppress(Exception):
@@ -237,6 +264,8 @@ async def _read_loop(session_ref: SessionRef, websocket) -> None:
if data: if data:
try: try:
await websocket.send_bytes(data) await websocket.send_bytes(data)
except WebSocketDisconnect:
break
except Exception: except Exception:
break break
else: else:
@@ -403,7 +432,9 @@ async def list_terminal_sessions(
) )
db_rows = result.scalars().all() 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 = [] sessions = []
for row in db_rows: for row in db_rows:
live_session = terminal_manager.get_session(str(instance_id), str(row.id)) live_session = terminal_manager.get_session(str(instance_id), str(row.id))
+4 -2
View File
@@ -131,6 +131,7 @@ class TerminalManager:
container_id: str, container_id: str,
startup_command: str | None = None, startup_command: str | None = None,
name: str | None = None, name: str | None = None,
session_id: str | None = None,
) -> TerminalSession: ) -> TerminalSession:
"""Create a new terminal session for an instance. """Create a new terminal session for an instance.
@@ -159,7 +160,8 @@ class TerminalManager:
instance_id_str, self.MAX_SESSIONS_PER_INSTANCE 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 = TerminalSession(
session_id=session_id, session_id=session_id,
instance_id=instance_id, instance_id=instance_id,
@@ -172,7 +174,7 @@ class TerminalManager:
key = (instance_id_str, session_id) key = (instance_id_str, session_id)
self._sessions[key] = session self._sessions[key] = session
# Fire-and-forget DB insert # Fire-and-forget DB insert (skip if row already exists)
asyncio.create_task( asyncio.create_task(
self._insert_db_session_row(session_id, instance_id, session.name) self._insert_db_session_row(session_id, instance_id, session.name)
) )
+11
View File
@@ -37,6 +37,7 @@ export interface TerminalProps {
export interface TerminalRef { export interface TerminalRef {
fit: () => void; fit: () => void;
focus: () => void;
} }
const FONT_SIZE_KEY = "terminal-font-size"; const FONT_SIZE_KEY = "terminal-font-size";
@@ -106,6 +107,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
console.log(`[Terminal ${sessionId ?? "default"}] WebSocket opened`);
setStatus("connected"); setStatus("connected");
setError(null); setError(null);
reconnectAttemptsRef.current = 0; reconnectAttemptsRef.current = 0;
@@ -137,6 +139,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
if (!termRef.current) return; if (!termRef.current) return;
if (event.data instanceof Blob) { 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) => { event.data.arrayBuffer().then((buffer) => {
const data = new Uint8Array(buffer); const data = new Uint8Array(buffer);
termRef.current?.write(data); termRef.current?.write(data);
@@ -306,6 +310,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Open xterm first (must happen before fit) // Open xterm first (must happen before fit)
term.open(container); term.open(container);
term.focus();
console.log(`[Terminal ${sessionId ?? "default"}] xterm opened and focused`);
const ws = connectWebSocket(); const ws = connectWebSocket();
// Initial fit after layout settles (terminal must be opened first) // Initial fit after layout settles (terminal must be opened first)
@@ -330,6 +336,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Handle terminal input // Handle terminal input
term.onData((data) => { term.onData((data) => {
// eslint-disable-next-line no-console
console.log(`[Terminal ${sessionId ?? "default"}] sending:`, JSON.stringify(data));
const currentWs = wsRef.current; const currentWs = wsRef.current;
if (currentWs?.readyState !== WebSocket.OPEN) return; if (currentWs?.readyState !== WebSocket.OPEN) return;
@@ -466,6 +474,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
} }
} }
}, },
focus: () => {
termRef.current?.focus();
},
})); }));
// Update parent about status changes // Update parent about status changes
+1 -1
View File
@@ -25,7 +25,7 @@ export function useTerminalSessions(
): UseTerminalSessionsResult { ): UseTerminalSessionsResult {
const [sessions, setSessions] = useState<TerminalSession[]>([]); const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null); const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
+29 -36
View File
@@ -39,7 +39,7 @@ export const TerminalPage: React.FC = () => {
error, error,
} = useTerminalSessions(instanceId ?? ""); } = useTerminalSessions(instanceId ?? "");
// Auto-create default session if none exist // Auto-create default session if none exist after loading completes
useEffect(() => { useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) { if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1"); void createSession("Session 1");
@@ -62,13 +62,14 @@ export const TerminalPage: React.FC = () => {
} }
}, [sessions]); }, [sessions]);
// Fit active terminal when switching tabs // Fit and focus active terminal when switching tabs
useEffect(() => { useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) { if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId]; const ref = terminalRefs.current[activeSessionId];
// Small delay to allow display:block to apply // Small delay to allow display:block to apply
const timer = setTimeout(() => { const timer = setTimeout(() => {
ref.current?.fit(); ref.current?.fit();
ref.current?.focus();
}, 50); }, 50);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
@@ -229,23 +230,19 @@ export const TerminalPage: React.FC = () => {
</div> </div>
<div className="terminal-page-content"> <div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>} {error && <div className="terminal-error-banner">{error}</div>}
{sessions.map((session) => ( {sessions
<div .filter((session) => session.id === activeSessionId)
key={session.id} .map((session) => (
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`} <div key={session.id} className="terminal-instance active">
style={{ <TerminalComponent
display: session.id === activeSessionId ? "flex" : "none", ref={terminalRefs.current[session.id]}
}} instanceId={instanceId}
> sessionId={session.id}
<TerminalComponent onClose={() => handleClose(session.id)}
ref={terminalRefs.current[session.id]} isMobile={true}
instanceId={instanceId} />
sessionId={session.id} </div>
onClose={() => handleClose(session.id)} ))}
isMobile={true}
/>
</div>
))}
{sessions.length === 0 && !loading && ( {sessions.length === 0 && !loading && (
<div className="terminal-empty-state"> <div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p> <p>No terminal sessions. Press Alt+Shift+N to create one.</p>
@@ -289,23 +286,19 @@ export const TerminalPage: React.FC = () => {
/> />
<div className="terminal-page-content"> <div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>} {error && <div className="terminal-error-banner">{error}</div>}
{sessions.map((session) => ( {sessions
<div .filter((session) => session.id === activeSessionId)
key={session.id} .map((session) => (
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`} <div key={session.id} className="terminal-instance active">
style={{ <TerminalComponent
display: session.id === activeSessionId ? "flex" : "none", ref={terminalRefs.current[session.id]}
}} instanceId={instanceId}
> sessionId={session.id}
<TerminalComponent onClose={() => handleClose(session.id)}
ref={terminalRefs.current[session.id]} isMobile={false}
instanceId={instanceId} />
sessionId={session.id} </div>
onClose={() => handleClose(session.id)} ))}
isMobile={false}
/>
</div>
))}
{sessions.length === 0 && !loading && ( {sessions.length === 0 && !loading && (
<div className="terminal-empty-state"> <div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p> <p>No terminal sessions. Press Alt+Shift+N to create one.</p>