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
+11
View File
@@ -3020,6 +3020,7 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-shrink: 0;
}
@@ -3027,16 +3028,25 @@
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
overflow: hidden;
}
.terminal-host-name {
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.terminal-host-ip {
color: #9ca3af;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.terminal-status-badge {
@@ -3059,6 +3069,7 @@
.terminal-header-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.terminal-btn {
+59 -5
View File
@@ -10327,10 +10327,23 @@ class DashboardManager {
}
// Create iframe for terminal
// Use embed mode so the connect page can hide its own header/pwa hint
// and let the drawer UI be the single source of controls.
const embeddedUrl = (() => {
try {
const url = new URL(session.url, window.location.origin);
url.searchParams.set('embed', '1');
return url.toString();
} catch (e) {
// Fallback for relative URLs
return session.url + (session.url.includes('?') ? '&' : '?') + 'embed=1';
}
})();
body.innerHTML = `
<iframe
id="terminalIframe"
src="${session.url}"
src="${embeddedUrl}"
class="terminal-iframe"
allow="clipboard-read; clipboard-write"
onload="dashboard.onTerminalIframeLoad()"
@@ -10376,7 +10389,8 @@ class DashboardManager {
this._terminalTimerInterval = setInterval(updateTimer, 1000);
}
async closeTerminalDrawer() {
async closeTerminalDrawer(options = {}) {
const { closeSession = true } = options;
const drawer = document.getElementById('terminalDrawer');
if (drawer) {
drawer.classList.remove('open');
@@ -10401,7 +10415,7 @@ class DashboardManager {
}
// Close session on server (best effort)
if (this.terminalSession) {
if (closeSession && this.terminalSession) {
try {
await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}`, {
method: 'DELETE'
@@ -10625,10 +10639,32 @@ class DashboardManager {
this.showNotification('Aucune session terminal active', 'warning');
return;
}
const popupUrl = this.terminalSession.url;
// If the drawer iframe is currently connected, ttyd is started with --once so
// we must release the existing client before opening the popout.
if (this.terminalDrawerOpen) {
try {
const iframe = document.getElementById('terminalIframe');
if (iframe) {
iframe.src = 'about:blank';
}
} catch (e) {
// Ignore
}
}
const sessionId = this.terminalSession.session_id;
const token = this.terminalSession.token;
const popupUrl = (sessionId && token)
? `/api/terminal/popout/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(token)}`
: this.terminalSession.url;
const popupFeatures = 'width=900,height=600,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no';
window.open(popupUrl, `terminal_${this.terminalSession.session_id}`, popupFeatures);
// Close the drawer UI but keep the backend session alive for the popout.
if (this.terminalDrawerOpen) {
this.closeTerminalDrawer({ closeSession: false });
}
}
copySSHCommand() {
@@ -10861,6 +10897,24 @@ document.addEventListener('DOMContentLoaded', () => {
console.log('Creating DashboardManager...');
window.dashboard = new DashboardManager();
console.log('DashboardManager created. Methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(dashboard)).filter(m => m.includes('Schedule')));
// Allow embedded terminal connect page to request closing the terminal drawer.
window.addEventListener('message', (event) => {
const data = event?.data;
if (!data || typeof data !== 'object') return;
if (data.type === 'terminal:closeDrawer') {
if (window.dashboard && typeof window.dashboard.closeTerminalDrawer === 'function') {
window.dashboard.closeTerminalDrawer();
}
}
if (data.type === 'terminal:reconnect') {
if (window.dashboard && typeof window.dashboard.reconnectTerminal === 'function') {
window.dashboard.reconnectTerminal();
}
}
});
});
// Global functions for onclick handlers
+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) => {{
+2 -2
View File
@@ -238,7 +238,7 @@ ssh -i "{ssh_key_path}" `
if self._pty_available:
cmd = [
TTYD_PATH,
"--once",
"--max-clients=1",
f"--port={session.port}",
"--writable",
gateway_script,
@@ -247,7 +247,7 @@ ssh -i "{ssh_key_path}" `
# Windows: Use ttyd with direct SSH command
cmd = [
TTYD_PATH,
"--once",
"--max-clients=1",
f"--port={session.port}",
"--writable",
"ssh",
+9 -2
View File
@@ -339,7 +339,8 @@ class TerminalService:
)
# Build ttyd command
# --once: Exit after single client disconnects
# NOTE: Do not use --once because it prevents browser reconnection.
# Use --max-clients=1 to keep a single active client while allowing reconnect.
# --credential: Basic auth with session token
# --port: Listen port
# -W: Write-only mode disabled (allow input)
@@ -366,12 +367,18 @@ class TerminalService:
]
ttyd_interface = TERMINAL_TTYD_INTERFACE
if ttyd_interface and ttyd_interface.strip().lower() in {"127.0.0.1", "localhost", "lo"}:
logger.warning(
"Ignoring TERMINAL_TTYD_INTERFACE=%s because binding ttyd to loopback breaks remote access/reconnect",
ttyd_interface,
)
ttyd_interface = None
# Note: --credential removed to avoid HTTP basic auth popup
# Security is handled by session token validation in the connect/popout routes
cmd = [
TTYD_PATH,
"--once",
"--max-clients=1",
f"--port={port}",
"--writable",
]