Enhance terminal drawer UI with text overflow handling, implement embed mode for seamless iframe integration, replace ttyd --once flag with --max-clients=1 to enable reconnection, add heartbeat mechanism for popout sessions, implement session reuse logic when opening popouts, and add postMessage communication between embedded terminal and dashboard for close/reconnect actions
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled

This commit is contained in:
2025-12-19 00:09:44 -05:00
parent 5bc12d0729
commit 5ff7017aaa
100 changed files with 21254 additions and 15 deletions
+116 -5
View File
@@ -8,6 +8,7 @@ Provides endpoints for:
- Serving terminal popout page
"""
import logging
import socket
from datetime import datetime, timedelta, timezone
from typing import Optional
@@ -42,6 +43,7 @@ from app.services.terminal_service import (
TERMINAL_MAX_SESSIONS_PER_USER,
TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS,
TERMINAL_HEARTBEAT_INTERVAL_SECONDS,
TERMINAL_TTYD_INTERFACE,
HostNotReadyError,
SessionLimitExceededError,
TtydNotAvailableError,
@@ -677,12 +679,54 @@ async def get_terminal_connect_page(
status_code=403
)
# If ttyd process is not alive, return a friendly error page instead of a blank iframe.
# If ttyd process is not alive, try to avoid false negatives:
# - the process might be running but not tracked in-memory (reload/multi-worker)
# - or it might have died and needs a respawn.
async def _is_port_reachable(port: int) -> bool:
candidates = ["127.0.0.1", "localhost"]
try:
if request.url.hostname:
candidates.append(request.url.hostname)
except Exception:
pass
ttyd_iface = TERMINAL_TTYD_INTERFACE
if ttyd_iface and ttyd_iface not in {"0.0.0.0", "::"}:
candidates.append(ttyd_iface)
for host_candidate in dict.fromkeys(candidates):
try:
with socket.create_connection((host_candidate, int(port)), timeout=0.35):
return True
except Exception:
continue
return False
alive = True
try:
alive = await terminal_service.is_session_process_alive(session_id)
except Exception:
alive = True
if not alive:
# If the port is reachable, assume ttyd is running but not tracked.
if await _is_port_reachable(session.ttyd_port):
alive = True
else:
# Try to respawn ttyd on the same allocated port.
try:
pid = await terminal_service.spawn_ttyd(
session_id=session_id,
host_ip=session.host_ip,
port=session.ttyd_port,
token=token,
)
if pid:
session.ttyd_pid = pid
await db_session.commit()
alive = True
except Exception:
alive = False
if not alive:
return HTMLResponse(
content=f"""
@@ -736,6 +780,8 @@ async def get_terminal_connect_page(
ttyd_url = f"{scheme}://{host_only.split(':')[0]}:{session.ttyd_port}/"
embed_mode = request.query_params.get("embed") in {"1", "true", "yes"}
# Generate the terminal page
html_content = f"""
<!DOCTYPE html>
@@ -878,10 +924,16 @@ async def get_terminal_connect_page(
cursor: pointer;
font-size: 0.75rem;
}}
body.embed #pwaHint {{
display: none !important;
}}
body.embed .terminal-header {{
display: none !important;
}}
.hidden {{ display: none !important; }}
</style>
</head>
<body>
<body class="{'embed' if embed_mode else ''}">
<div class="pwa-hint" id="pwaHint">
<span>💡 Pour une expérience sans barre d'outils : installez en PWA ou utilisez <code>chrome --app=URL</code></span>
<button onclick="dismissPwaHint()">✕ Fermer</button>
@@ -951,6 +1003,12 @@ async def get_terminal_connect_page(
const HOST_NAME = '{session.host_name}';
const HOST_IP = '{session.host_ip}';
let remainingSeconds = {remaining_seconds};
const EMBED = {str(embed_mode).lower()};
// Keep the session alive when running in popout / standalone mode.
// In embedded mode, the dashboard owns the heartbeat.
const HEARTBEAT_INTERVAL_MS = {TERMINAL_HEARTBEAT_INTERVAL_SECONDS * 1000};
let heartbeatTimer = null;
// Point iframe to ttyd using the current page hostname (avoid hardcoded localhost)
// This matters when accessing the dashboard remotely or behind a proxy.
@@ -1011,6 +1069,32 @@ async def get_terminal_connect_page(
// Focus the iframe
document.getElementById('terminalFrame').focus();
}}
async function sendHeartbeat() {{
if (EMBED) return;
try {{
const headers = {{ 'Content-Type': 'application/json' }};
const token = localStorage.getItem('token');
if (token) {{
headers['Authorization'] = 'Bearer ' + token;
}}
await fetch('/api/terminal/sessions/' + SESSION_ID + '/heartbeat', {{
method: 'POST',
headers,
body: JSON.stringify({{}})
}});
}} catch (e) {{
// Best effort
}}
}}
function startHeartbeat() {{
if (EMBED) return;
if (heartbeatTimer) return;
sendHeartbeat();
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
}}
function copySSHCommand() {{
const cmd = 'ssh automation@' + HOST_IP;
@@ -1020,12 +1104,36 @@ async def get_terminal_connect_page(
}}
function reconnect() {{
document.getElementById('terminalLoading').classList.remove('hidden');
document.getElementById('terminalFrame').src =
document.getElementById('terminalFrame').src;
// Reload the wrapper page so the backend can check/respawn ttyd if needed.
// Reloading only the ttyd iframe can get stuck on connection refused.
if (EMBED) {{
try {{
window.parent.postMessage({{ type: 'terminal:reconnect' }}, '*');
}} catch (e) {{
// Ignore
}}
return;
}}
try {{
document.getElementById('terminalLoading').classList.remove('hidden');
}} catch (e) {{
// Ignore
}}
const url = new URL(window.location.href);
// Cache-buster to avoid stale state
url.searchParams.set('_ts', Date.now().toString());
window.location.replace(url.toString());
}}
function goToDashboard() {{
if (EMBED) {{
try {{
window.parent.postMessage({{ type: 'terminal:closeDrawer' }}, '*');
}} catch (e) {{
// Ignore
}}
return;
}}
if (window.opener) {{
window.close();
}} else {{
@@ -1058,6 +1166,9 @@ async def get_terminal_connect_page(
window.matchMedia('(display-mode: standalone)').matches) {{
document.getElementById('pwaHint').classList.add('hidden');
}}
// Start heartbeat in popout mode
startHeartbeat();
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {{