Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user