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
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:
@@ -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
@@ -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
@@ -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) => {{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user