Add terminal session management with heartbeat monitoring, idle timeout detection, session reuse logic, and command history panel UI with search and filtering capabilities
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:
+593
-28
@@ -19,21 +19,39 @@ from app.core.dependencies import get_db, get_current_user
|
||||
from app.crud.host import HostRepository
|
||||
from app.crud.bootstrap_status import BootstrapStatusRepository
|
||||
from app.crud.terminal_session import TerminalSessionRepository
|
||||
from app.crud.terminal_command_log import TerminalCommandLogRepository
|
||||
from app.schemas.terminal import (
|
||||
TerminalSessionRequest,
|
||||
TerminalSessionResponse,
|
||||
TerminalSessionHost,
|
||||
TerminalSessionStatus,
|
||||
TerminalSessionList,
|
||||
CommandHistoryItem,
|
||||
CommandHistoryResponse,
|
||||
UniqueCommandItem,
|
||||
UniqueCommandsResponse,
|
||||
ActiveSessionInfo,
|
||||
SessionLimitError,
|
||||
HeartbeatResponse,
|
||||
SessionMetrics,
|
||||
)
|
||||
from app.services.terminal_service import (
|
||||
terminal_service,
|
||||
TERMINAL_SESSION_TTL_MINUTES,
|
||||
TERMINAL_SESSION_TTL_SECONDS,
|
||||
TERMINAL_MAX_SESSIONS_PER_USER,
|
||||
TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS,
|
||||
TERMINAL_HEARTBEAT_INTERVAL_SECONDS,
|
||||
HostNotReadyError,
|
||||
SessionLimitExceededError,
|
||||
TtydNotAvailableError,
|
||||
)
|
||||
from app.models.terminal_session import (
|
||||
SESSION_STATUS_ACTIVE,
|
||||
SESSION_STATUS_CLOSED,
|
||||
CLOSE_REASON_USER,
|
||||
CLOSE_REASON_CLIENT_LOST,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -62,24 +80,82 @@ async def get_terminal_feature_status():
|
||||
"ttyd_installed": ttyd_available,
|
||||
"max_sessions_per_user": TERMINAL_MAX_SESSIONS_PER_USER,
|
||||
"session_ttl_minutes": TERMINAL_SESSION_TTL_MINUTES,
|
||||
"session_idle_timeout_seconds": TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS,
|
||||
"heartbeat_interval_seconds": TERMINAL_HEARTBEAT_INTERVAL_SECONDS,
|
||||
"active_sessions": terminal_service.get_active_session_count(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/metrics", response_model=SessionMetrics)
|
||||
async def get_terminal_metrics(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get terminal session service metrics.
|
||||
|
||||
Returns counts of sessions created, reused, closed, etc.
|
||||
"""
|
||||
metrics = terminal_service.get_metrics()
|
||||
return SessionMetrics(**metrics)
|
||||
|
||||
|
||||
def _build_session_limit_error(
|
||||
active_sessions: list,
|
||||
reusable_session=None,
|
||||
) -> dict:
|
||||
"""
|
||||
Build a rich error response for session limit exceeded.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
active_info = []
|
||||
for s in active_sessions:
|
||||
created_at = _as_utc_aware(s.created_at) or now
|
||||
last_seen_at = _as_utc_aware(getattr(s, 'last_seen_at', None)) or created_at
|
||||
|
||||
active_info.append(ActiveSessionInfo(
|
||||
session_id=s.id,
|
||||
host_id=s.host_id,
|
||||
host_name=s.host_name,
|
||||
mode=s.mode,
|
||||
age_seconds=int((now - created_at).total_seconds()),
|
||||
last_seen_seconds=int((now - last_seen_at).total_seconds()),
|
||||
))
|
||||
|
||||
suggested_actions = ["close_oldest"]
|
||||
for s in active_sessions:
|
||||
suggested_actions.append(f"close_session:{s.id}")
|
||||
|
||||
can_reuse = reusable_session is not None
|
||||
if can_reuse:
|
||||
suggested_actions.insert(0, "reuse_existing")
|
||||
|
||||
return SessionLimitError(
|
||||
message=f"Maximum active sessions ({TERMINAL_MAX_SESSIONS_PER_USER}) reached. Close an existing session first.",
|
||||
max_active=TERMINAL_MAX_SESSIONS_PER_USER,
|
||||
current_count=len(active_sessions),
|
||||
active_sessions=active_info,
|
||||
suggested_actions=suggested_actions,
|
||||
can_reuse=can_reuse,
|
||||
reusable_session_id=reusable_session.id if reusable_session else None,
|
||||
).model_dump()
|
||||
|
||||
|
||||
@router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse)
|
||||
async def create_terminal_session(
|
||||
host_id: str,
|
||||
http_request: Request,
|
||||
request: TerminalSessionRequest = TerminalSessionRequest(),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create a new terminal session for a host.
|
||||
Create or reuse a terminal session for a host.
|
||||
|
||||
Requires:
|
||||
- Host must exist and be online
|
||||
- Host must have bootstrap completed
|
||||
- User must not exceed max sessions limit
|
||||
Session reuse policy:
|
||||
- If an active, healthy session exists for same user/host/mode, reuse it
|
||||
- Otherwise create a new session if quota allows
|
||||
- If quota exceeded, return rich error with active sessions list
|
||||
|
||||
Returns session URL and metadata.
|
||||
"""
|
||||
@@ -126,15 +202,79 @@ async def create_terminal_session(
|
||||
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
|
||||
username = current_user.get("username", "api_user")
|
||||
|
||||
# Check session limit
|
||||
active_count = await session_repo.count_active_for_user(user_id)
|
||||
if active_count >= TERMINAL_MAX_SESSIONS_PER_USER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Maximum active sessions ({TERMINAL_MAX_SESSIONS_PER_USER}) reached. Close an existing session first."
|
||||
# Check for reusable session first
|
||||
existing_session = await session_repo.find_reusable_session(
|
||||
user_id=user_id,
|
||||
host_id=host.id,
|
||||
mode=request.mode,
|
||||
idle_timeout_seconds=TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
if existing_session:
|
||||
# Reuse existing session - update last_seen and return
|
||||
await session_repo.update_last_seen(existing_session.id)
|
||||
await db_session.commit()
|
||||
|
||||
terminal_service.record_session_created(reused=True)
|
||||
logger.info(f"session_reused session={existing_session.id[:8]}... user={username} host={host.name}")
|
||||
|
||||
expires_at = _as_utc_aware(existing_session.expires_at)
|
||||
now = datetime.now(timezone.utc)
|
||||
remaining = max(0, int(((expires_at or now) - now).total_seconds()))
|
||||
|
||||
# Note: We don't have the original token, so client must use the existing connection
|
||||
# or we generate a new token and update the session
|
||||
token, token_hash = terminal_service.generate_session_token()
|
||||
existing_session.token_hash = token_hash
|
||||
await db_session.commit()
|
||||
|
||||
connect_path = f"/api/terminal/connect/{existing_session.id}?token={token}"
|
||||
popout_path = f"/api/terminal/popout/{existing_session.id}?token={token}"
|
||||
session_url = popout_path if request.mode == "popout" else connect_path
|
||||
|
||||
ws_scheme = "wss" if (http_request and http_request.url.scheme == "https") else "ws"
|
||||
ws_host = (http_request.url.hostname if http_request else "localhost")
|
||||
|
||||
return TerminalSessionResponse(
|
||||
session_id=existing_session.id,
|
||||
url=session_url,
|
||||
websocket_url=f"{ws_scheme}://{ws_host}:{existing_session.ttyd_port}/ws",
|
||||
expires_at=expires_at,
|
||||
ttl_seconds=remaining,
|
||||
mode=existing_session.mode,
|
||||
host=TerminalSessionHost(
|
||||
id=host.id,
|
||||
name=host.name,
|
||||
ip=host.ip_address,
|
||||
status=host.status or "unknown",
|
||||
bootstrap_ok=bootstrap_ok,
|
||||
),
|
||||
reused=True,
|
||||
token=token,
|
||||
)
|
||||
|
||||
# Generate session credentials
|
||||
# No reusable session - check quota
|
||||
active_sessions = await session_repo.list_active_for_user(user_id)
|
||||
active_count = len(active_sessions)
|
||||
|
||||
if active_count >= TERMINAL_MAX_SESSIONS_PER_USER:
|
||||
# Check if any session is for the same host (can be reused with different mode)
|
||||
same_host_session = next(
|
||||
(s for s in active_sessions if s.host_id == host.id),
|
||||
None
|
||||
)
|
||||
|
||||
terminal_service.record_session_limit_hit()
|
||||
logger.warning(f"session_limit_hit user={username} count={active_count} max={TERMINAL_MAX_SESSIONS_PER_USER}")
|
||||
|
||||
# Return rich error with session list
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
content=_build_session_limit_error(active_sessions, same_host_session),
|
||||
)
|
||||
|
||||
# Create new session
|
||||
session_id = terminal_service.generate_session_id()
|
||||
token, token_hash = terminal_service.generate_session_token()
|
||||
|
||||
@@ -208,18 +348,20 @@ async def create_terminal_session(
|
||||
detail="Failed to create terminal session"
|
||||
)
|
||||
|
||||
# Log audit event
|
||||
logger.info(f"Terminal session created: user={username} host={host.name} session={session_id[:8]}...")
|
||||
terminal_service.record_session_created(reused=False)
|
||||
logger.info(f"session_created session={session_id[:8]}... user={username} host={host.name}")
|
||||
|
||||
connect_path = f"/api/terminal/connect/{session_id}?token={token}"
|
||||
popout_path = f"/api/terminal/popout/{session_id}?token={token}"
|
||||
session_url = popout_path if request.mode == "popout" else connect_path
|
||||
|
||||
# Build response
|
||||
ws_scheme = "wss" if (http_request and http_request.url.scheme == "https") else "ws"
|
||||
ws_host = (http_request.url.hostname if http_request else "localhost")
|
||||
|
||||
return TerminalSessionResponse(
|
||||
session_id=session_id,
|
||||
url=session_url,
|
||||
websocket_url=f"ws://localhost:{port}/ws",
|
||||
websocket_url=f"{ws_scheme}://{ws_host}:{port}/ws",
|
||||
expires_at=expires_at,
|
||||
ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60,
|
||||
mode=request.mode,
|
||||
@@ -230,16 +372,22 @@ async def create_terminal_session(
|
||||
status=host.status or "unknown",
|
||||
bootstrap_ok=bootstrap_ok,
|
||||
),
|
||||
reused=False,
|
||||
token=token,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=TerminalSessionList)
|
||||
async def list_terminal_sessions(
|
||||
status_filter: Optional[str] = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List active terminal sessions for the current user.
|
||||
|
||||
Args:
|
||||
status_filter: Optional filter by status (default: active only)
|
||||
"""
|
||||
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
|
||||
|
||||
@@ -252,14 +400,23 @@ async def list_terminal_sessions(
|
||||
for session in sessions:
|
||||
expires_at = _as_utc_aware(session.expires_at)
|
||||
created_at = _as_utc_aware(session.created_at)
|
||||
last_seen_at = _as_utc_aware(getattr(session, 'last_seen_at', None)) or created_at
|
||||
remaining = max(0, int(((expires_at or now) - now).total_seconds()))
|
||||
age_seconds = int((now - created_at).total_seconds())
|
||||
last_seen_seconds = int((now - last_seen_at).total_seconds())
|
||||
|
||||
session_list.append(TerminalSessionStatus(
|
||||
session_id=session.id,
|
||||
status=session.status,
|
||||
host_id=session.host_id,
|
||||
host_name=session.host_name,
|
||||
mode=session.mode,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
last_seen_at=last_seen_at,
|
||||
remaining_seconds=remaining,
|
||||
age_seconds=age_seconds,
|
||||
last_seen_seconds=last_seen_seconds,
|
||||
))
|
||||
|
||||
return TerminalSessionList(
|
||||
@@ -279,6 +436,7 @@ async def close_terminal_session(
|
||||
Close a terminal session.
|
||||
|
||||
Terminates the ttyd process and marks session as closed.
|
||||
Idempotent: closing an already closed session returns 204.
|
||||
"""
|
||||
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
|
||||
username = current_user.get("username", "api_user")
|
||||
@@ -287,10 +445,12 @@ async def close_terminal_session(
|
||||
session = await session_repo.get(session_id)
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found"
|
||||
)
|
||||
# Idempotent: session doesn't exist, consider it closed
|
||||
return {"message": "Session not found or already closed", "session_id": session_id}
|
||||
|
||||
# Already closed - idempotent success
|
||||
if session.status in [SESSION_STATUS_CLOSED, "expired", "error"]:
|
||||
return {"message": "Session already closed", "session_id": session_id}
|
||||
|
||||
# Check ownership (unless admin)
|
||||
if session.user_id != user_id and current_user.get("role") != "admin":
|
||||
@@ -299,19 +459,106 @@ async def close_terminal_session(
|
||||
detail="Cannot close another user's session"
|
||||
)
|
||||
|
||||
# Terminate ttyd process
|
||||
await terminal_service.terminate_session(session_id)
|
||||
await terminal_service.release_port(session.ttyd_port)
|
||||
# Terminate ttyd process and cleanup
|
||||
await terminal_service.close_session_with_cleanup(
|
||||
session_id=session_id,
|
||||
port=session.ttyd_port,
|
||||
reason=CLOSE_REASON_USER
|
||||
)
|
||||
|
||||
# Update database
|
||||
await session_repo.close_session(session_id)
|
||||
await session_repo.close_session(session_id, reason=CLOSE_REASON_USER)
|
||||
await db_session.commit()
|
||||
|
||||
logger.info(f"Terminal session closed: user={username} host={session.host_name} session={session_id[:8]}...")
|
||||
logger.info(f"session_closed session={session_id[:8]}... user={username} host={session.host_name} reason=user_close")
|
||||
|
||||
return {"message": "Session closed", "session_id": session_id}
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/heartbeat", response_model=HeartbeatResponse)
|
||||
async def heartbeat_terminal_session(
|
||||
session_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Send heartbeat to keep session alive.
|
||||
|
||||
Updates last_seen_at timestamp. Should be called every 10-20 seconds
|
||||
while terminal is visible/active.
|
||||
"""
|
||||
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
|
||||
|
||||
session_repo = TerminalSessionRepository(db_session)
|
||||
session = await session_repo.get_active_by_id(session_id)
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found or expired"
|
||||
)
|
||||
|
||||
# Check ownership
|
||||
if session.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot heartbeat another user's session"
|
||||
)
|
||||
|
||||
# Update last_seen
|
||||
await session_repo.update_last_seen(session_id)
|
||||
await db_session.commit()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = _as_utc_aware(session.expires_at)
|
||||
remaining = max(0, int(((expires_at or now) - now).total_seconds()))
|
||||
|
||||
return HeartbeatResponse(
|
||||
session_id=session_id,
|
||||
status=session.status,
|
||||
last_seen_at=now,
|
||||
remaining_seconds=remaining,
|
||||
healthy=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/close-beacon")
|
||||
async def close_beacon_terminal_session(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Close session via sendBeacon (for browser close/navigation).
|
||||
|
||||
This endpoint is designed to be called via navigator.sendBeacon()
|
||||
when the browser tab is closing or navigating away. It does not
|
||||
require authentication since the session_id itself is the credential.
|
||||
|
||||
Always returns 204 No Content for sendBeacon compatibility.
|
||||
"""
|
||||
session_repo = TerminalSessionRepository(db_session)
|
||||
session = await session_repo.get(session_id)
|
||||
|
||||
if session and session.status == SESSION_STATUS_ACTIVE:
|
||||
# Terminate ttyd process and cleanup
|
||||
await terminal_service.close_session_with_cleanup(
|
||||
session_id=session_id,
|
||||
port=session.ttyd_port,
|
||||
reason=CLOSE_REASON_CLIENT_LOST
|
||||
)
|
||||
|
||||
# Update database
|
||||
await session_repo.close_session(session_id, reason=CLOSE_REASON_CLIENT_LOST)
|
||||
await db_session.commit()
|
||||
|
||||
logger.info(f"session_closed session={session_id[:8]}... host={session.host_name} reason=client_lost")
|
||||
|
||||
# Always return 204 for sendBeacon
|
||||
from fastapi.responses import Response
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def cleanup_terminal_sessions(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -363,6 +610,7 @@ async def cleanup_terminal_sessions(
|
||||
async def get_terminal_connect_page(
|
||||
session_id: str,
|
||||
token: str,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -428,11 +676,65 @@ 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.
|
||||
try:
|
||||
alive = await terminal_service.is_session_process_alive(session_id)
|
||||
except Exception:
|
||||
alive = True
|
||||
|
||||
if not alive:
|
||||
return HTMLResponse(
|
||||
content=f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{session.host_name}</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui; background: #1a1a2e; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100vh; margin: 0; }}
|
||||
.error {{ text-align: center; padding: 2rem; max-width: 520px; }}
|
||||
.error h1 {{ color: #ef4444; margin-bottom: 0.75rem; }}
|
||||
.error p {{ color: #e5e7eb; margin-bottom: 0.5rem; }}
|
||||
.error a {{ color: #60a5fa; }}
|
||||
.btn {{ display: inline-block; margin-top: 1rem; padding: 0.5rem 0.9rem;
|
||||
background: #7c3aed; color: #fff; text-decoration: none; border-radius: 0.5rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error">
|
||||
<h1>Terminal indisponible</h1>
|
||||
<p>Le service terminal (ttyd) ne répond pas pour cette session.</p>
|
||||
<p>Essayez de reconnecter ou de recréer une session depuis le dashboard.</p>
|
||||
<a class="btn" href="/">Retour au Dashboard</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
# Calculate remaining time
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = _as_utc_aware(session.expires_at)
|
||||
remaining_seconds = max(0, int(((expires_at or now) - now).total_seconds()))
|
||||
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto")
|
||||
forwarded_host = request.headers.get("x-forwarded-host")
|
||||
forwarded_port = request.headers.get("x-forwarded-port")
|
||||
|
||||
scheme = forwarded_proto or request.url.scheme
|
||||
host = forwarded_host or request.headers.get("host") or request.url.hostname or "localhost"
|
||||
if ":" in host:
|
||||
host_only = host
|
||||
else:
|
||||
host_only = host
|
||||
|
||||
if forwarded_port and ":" not in host_only:
|
||||
host_only = f"{host_only}:{forwarded_port}"
|
||||
|
||||
ttyd_url = f"{scheme}://{host_only.split(':')[0]}:{session.ttyd_port}/"
|
||||
|
||||
# Generate the terminal page
|
||||
html_content = f"""
|
||||
@@ -441,7 +743,7 @@ async def get_terminal_connect_page(
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Terminal - {session.host_name}</title>
|
||||
<title>{session.host_name}</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{
|
||||
@@ -637,7 +939,7 @@ async def get_terminal_connect_page(
|
||||
</div>
|
||||
<iframe
|
||||
id="terminalFrame"
|
||||
src="http://localhost:{session.ttyd_port}/"
|
||||
src="about:blank"
|
||||
onload="onTerminalLoad()"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
></iframe>
|
||||
@@ -649,6 +951,35 @@ async def get_terminal_connect_page(
|
||||
const HOST_NAME = '{session.host_name}';
|
||||
const HOST_IP = '{session.host_ip}';
|
||||
let remainingSeconds = {remaining_seconds};
|
||||
|
||||
// Point iframe to ttyd using the current page hostname (avoid hardcoded localhost)
|
||||
// This matters when accessing the dashboard remotely or behind a proxy.
|
||||
(function setTerminalSrc() {{
|
||||
const url = '{ttyd_url}';
|
||||
document.getElementById('terminalFrame').src = url;
|
||||
}})();
|
||||
|
||||
// If the iframe never loads, show a clear error instead of a blank page.
|
||||
let iframeLoaded = false;
|
||||
const iframeTimeoutMs = 8000;
|
||||
setTimeout(() => {{
|
||||
if (iframeLoaded) return;
|
||||
const loading = document.getElementById('terminalLoading');
|
||||
if (loading) {{
|
||||
loading.classList.remove('hidden');
|
||||
loading.innerHTML = `
|
||||
<div style="text-align:center; max-width:520px;">
|
||||
<div style="font-size:1.1rem; font-weight:600; color:#ef4444; margin-bottom:0.5rem;">Terminal injoignable</div>
|
||||
<div style="color:#e5e7eb; margin-bottom:0.75rem;">Le navigateur n'a reçu aucune réponse du service ttyd.</div>
|
||||
<div style="color:#9ca3af; margin-bottom:0.75rem;">WSL/Firewall/port non exposé ou process ttyd arrêté.</div>
|
||||
<div style="display:flex; justify-content:center; gap:0.5rem;">
|
||||
<button class="btn btn-secondary" onclick="reconnect()">Reconnecter</button>
|
||||
<button class="btn btn-danger" onclick="goToDashboard()">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}}
|
||||
}}, iframeTimeoutMs);
|
||||
|
||||
// Timer countdown
|
||||
function updateTimer() {{
|
||||
@@ -675,6 +1006,7 @@ async def get_terminal_connect_page(
|
||||
setInterval(updateTimer, 1000);
|
||||
|
||||
function onTerminalLoad() {{
|
||||
iframeLoaded = true;
|
||||
document.getElementById('terminalLoading').classList.add('hidden');
|
||||
// Focus the iframe
|
||||
document.getElementById('terminalFrame').focus();
|
||||
@@ -745,6 +1077,7 @@ async def get_terminal_connect_page(
|
||||
async def get_terminal_popout_page(
|
||||
session_id: str,
|
||||
token: str,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -753,4 +1086,236 @@ async def get_terminal_popout_page(
|
||||
This is designed to be opened in a popup window or PWA.
|
||||
"""
|
||||
# Reuse the same connect page logic but with minimal header
|
||||
return await get_terminal_connect_page(session_id, token, db_session)
|
||||
return await get_terminal_connect_page(session_id, token, request, db_session)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Command History Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/{host_id}/command-history", response_model=CommandHistoryResponse)
|
||||
async def get_host_command_history(
|
||||
host_id: str,
|
||||
query: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get command history for a specific host.
|
||||
|
||||
Args:
|
||||
host_id: Host ID to get history for
|
||||
query: Optional search query to filter commands
|
||||
limit: Maximum number of results (default 50)
|
||||
offset: Number of results to skip for pagination
|
||||
|
||||
Returns:
|
||||
List of commands with timestamps and metadata
|
||||
"""
|
||||
# Verify host exists
|
||||
host_repo = HostRepository(db_session)
|
||||
host = await host_repo.get(host_id)
|
||||
if not host:
|
||||
host = await host_repo.get_by_name(host_id)
|
||||
if not host:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Host '{host_id}' not found"
|
||||
)
|
||||
|
||||
# Get command history
|
||||
cmd_repo = TerminalCommandLogRepository(db_session)
|
||||
logs = await cmd_repo.list_for_host(
|
||||
host_id=host.id,
|
||||
query=query,
|
||||
limit=min(limit, 100), # Cap at 100
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
total = await cmd_repo.count_for_host(host.id)
|
||||
|
||||
commands = [
|
||||
CommandHistoryItem(
|
||||
id=log.id,
|
||||
command=log.command,
|
||||
created_at=log.created_at,
|
||||
host_name=log.host_name,
|
||||
username=log.username,
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
return CommandHistoryResponse(
|
||||
commands=commands,
|
||||
total=total,
|
||||
host_id=host.id,
|
||||
query=query,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{host_id}/command-history/unique", response_model=UniqueCommandsResponse)
|
||||
async def get_host_unique_commands(
|
||||
host_id: str,
|
||||
query: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get unique commands for a host (deduplicated).
|
||||
|
||||
Returns each unique command once with execution count and last used time.
|
||||
Useful for command suggestions/autocomplete.
|
||||
"""
|
||||
# Verify host exists
|
||||
host_repo = HostRepository(db_session)
|
||||
host = await host_repo.get(host_id)
|
||||
if not host:
|
||||
host = await host_repo.get_by_name(host_id)
|
||||
if not host:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Host '{host_id}' not found"
|
||||
)
|
||||
|
||||
# Get unique commands
|
||||
cmd_repo = TerminalCommandLogRepository(db_session)
|
||||
unique_cmds = await cmd_repo.get_unique_commands_for_host(
|
||||
host_id=host.id,
|
||||
query=query,
|
||||
limit=min(limit, 100),
|
||||
)
|
||||
|
||||
commands = [
|
||||
UniqueCommandItem(
|
||||
command=cmd["command"],
|
||||
command_hash=cmd["command_hash"],
|
||||
last_used=cmd["last_used"],
|
||||
execution_count=cmd["execution_count"],
|
||||
)
|
||||
for cmd in unique_cmds
|
||||
]
|
||||
|
||||
return UniqueCommandsResponse(
|
||||
commands=commands,
|
||||
total=len(commands),
|
||||
host_id=host.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/command-history", response_model=CommandHistoryResponse)
|
||||
async def get_global_command_history(
|
||||
query: Optional[str] = None,
|
||||
host_id: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get command history globally (across all hosts).
|
||||
|
||||
Args:
|
||||
query: Optional search query to filter commands
|
||||
host_id: Optional host ID to filter by
|
||||
limit: Maximum number of results (default 50)
|
||||
offset: Number of results to skip for pagination
|
||||
|
||||
Returns:
|
||||
List of commands with timestamps and metadata
|
||||
"""
|
||||
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
|
||||
|
||||
cmd_repo = TerminalCommandLogRepository(db_session)
|
||||
logs = await cmd_repo.list_global(
|
||||
query=query,
|
||||
host_id=host_id,
|
||||
user_id=str(user_id) if user_id else None,
|
||||
limit=min(limit, 100),
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
commands = [
|
||||
CommandHistoryItem(
|
||||
id=log.id,
|
||||
command=log.command,
|
||||
created_at=log.created_at,
|
||||
host_name=log.host_name,
|
||||
username=log.username,
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
return CommandHistoryResponse(
|
||||
commands=commands,
|
||||
total=len(commands),
|
||||
host_id=host_id,
|
||||
query=query,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{host_id}/command-history")
|
||||
async def clear_host_command_history(
|
||||
host_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Clear command history for a specific host.
|
||||
|
||||
Requires admin role.
|
||||
"""
|
||||
# Check admin permission
|
||||
if current_user.get("role") != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can clear command history"
|
||||
)
|
||||
|
||||
# Verify host exists
|
||||
host_repo = HostRepository(db_session)
|
||||
host = await host_repo.get(host_id)
|
||||
if not host:
|
||||
host = await host_repo.get_by_name(host_id)
|
||||
if not host:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Host '{host_id}' not found"
|
||||
)
|
||||
|
||||
cmd_repo = TerminalCommandLogRepository(db_session)
|
||||
deleted = await cmd_repo.delete_for_host(host.id)
|
||||
await db_session.commit()
|
||||
|
||||
logger.info(f"Cleared {deleted} command logs for host {host.name}")
|
||||
|
||||
return {"message": f"Cleared {deleted} command logs", "host_id": host.id}
|
||||
|
||||
|
||||
@router.post("/command-history/purge")
|
||||
async def purge_old_command_history(
|
||||
days: int = 30,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db_session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Purge command history older than specified days.
|
||||
|
||||
Requires admin role.
|
||||
"""
|
||||
# Check admin permission
|
||||
if current_user.get("role") != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can purge command history"
|
||||
)
|
||||
|
||||
cmd_repo = TerminalCommandLogRepository(db_session)
|
||||
deleted = await cmd_repo.purge_old_logs(days=days)
|
||||
await db_session.commit()
|
||||
|
||||
logger.info(f"Purged {deleted} command logs older than {days} days")
|
||||
|
||||
return {"message": f"Purged {deleted} command logs", "retention_days": days}
|
||||
|
||||
Reference in New Issue
Block a user