Bruno Charest 6d8432169b
Some checks failed
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
Add enhanced terminal history panel UI with animations, keyboard navigation, advanced filtering, search highlighting, and improved storage metrics display with detailed filesystem tables and ZFS/LVM support
2025-12-21 12:31:08 -05:00

2496 lines
95 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Routes API pour les sessions terminal SSH web.
Provides endpoints for:
- Creating terminal sessions
- Listing active sessions
- Closing sessions
- Serving terminal popout page
"""
import asyncio
import json
import hashlib
import httpx
import logging
import socket
import html
from urllib.parse import urlencode, urlparse, parse_qs
from datetime import datetime, timedelta, timezone
from typing import Optional
from app.services.shell_history_service import shell_history_service, ShellHistoryError
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from fastapi.responses import HTMLResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
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,
TERMINAL_TTYD_INTERFACE,
TtydNotAvailableError,
)
from app.models.terminal_session import (
SESSION_STATUS_ACTIVE,
SESSION_STATUS_CLOSED,
CLOSE_REASON_USER,
CLOSE_REASON_CLIENT_LOST,
)
logger = logging.getLogger(__name__)
router = APIRouter()
_PORT_REACHABILITY_TIMEOUT_SECONDS = 1.0
_MAX_TERMINAL_COMMAND_LENGTH = 10000
def _as_utc_aware(dt: Optional[datetime]) -> Optional[datetime]:
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _compute_remaining_seconds(expires_at: Optional[datetime], now: Optional[datetime] = None) -> int:
now = now or datetime.now(timezone.utc)
expires_at = _as_utc_aware(expires_at)
if not expires_at:
return 0
delta = (expires_at - now).total_seconds()
return max(0, int(delta))
async def _is_port_reachable(request: Request, 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=_PORT_REACHABILITY_TIMEOUT_SECONDS):
return True
except Exception:
continue
return False
def _get_session_token_from_headers(auth_header: Optional[str]) -> Optional[str]:
if not auth_header:
return None
if not auth_header.startswith("Bearer "):
return None
parts = auth_header.split(" ", 1)
return parts[1].strip() if len(parts) == 2 else None
def _get_session_token_from_request(request: Request, session_id: str, token: Optional[str] = None) -> Optional[str]:
if token:
return token
cookie_key = f"terminal_token_{session_id}"
cookie_token = request.cookies.get(cookie_key) or request.cookies.get("terminal_token")
if cookie_token:
return cookie_token
hdr_token = _get_session_token_from_headers(request.headers.get("authorization"))
if hdr_token:
return hdr_token
referer = request.headers.get("referer")
if referer:
try:
referer_qs = parse_qs(urlparse(referer).query)
referer_token = referer_qs.get("token", [None])[0]
if referer_token:
return referer_token
except Exception:
pass
return None
def _get_session_token_from_websocket(websocket: WebSocket, session_id: str) -> Optional[str]:
token = websocket.query_params.get("token")
if token:
return token
cookie_key = f"terminal_token_{session_id}"
cookie_token = websocket.cookies.get(cookie_key) or websocket.cookies.get("terminal_token")
return cookie_token
@router.get("/status")
async def get_terminal_status(current_user: dict = Depends(get_current_user)):
available = bool(terminal_service.check_ttyd_available())
return {
"available": available,
}
@router.get("/sessions/{session_id}/probe")
async def probe_terminal_session(
session_id: str,
token: str,
request: Request,
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
if not terminal_service.verify_token(token, session.token_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
alive = False
try:
alive = await terminal_service.is_session_process_alive(session_id)
except Exception:
alive = False
reachable = await _is_port_reachable(request, session.ttyd_port)
return {
"session_id": session.id,
"ttyd_port": session.ttyd_port,
"process_alive": alive,
"port_reachable": reachable,
"ttyd_interface": TERMINAL_TTYD_INTERFACE,
"dashboard_scheme": request.url.scheme,
"dashboard_host": request.url.hostname,
}
@router.get("/proxy/{session_id}")
async def proxy_ttyd_http(
session_id: str,
request: Request,
token: Optional[str] = None,
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
effective_token = _get_session_token_from_request(request, session_id, token=token)
if not effective_token:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing session token")
if not terminal_service.verify_token(effective_token, session.token_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
prefix = f"/api/terminal/proxy/{session_id}"
path = request.url.path
if path.startswith(prefix):
path = path[len(prefix):]
if not path:
path = "/"
query_params = list(request.query_params.multi_items())
query_params = [(k, v) for (k, v) in query_params if k != "token"]
query_string = urlencode(query_params, doseq=True) if query_params else ""
ttyd_url = f"http://127.0.0.1:{session.ttyd_port}{path}"
if query_string:
ttyd_url += f"?{query_string}"
cookie_key = f"terminal_token_{session_id}"
cookie_token = request.cookies.get(cookie_key) or request.cookies.get("terminal_token")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
ttyd_response = await client.request(
method=request.method,
url=ttyd_url,
headers={k: v for k, v in request.headers.items() if k.lower() not in {"host", "connection"}},
content=await request.body() if request.method in {"POST", "PUT", "PATCH"} else None,
follow_redirects=True,
)
drop_headers = {
"content-encoding",
"transfer-encoding",
"content-security-policy",
"x-frame-options",
"content-length",
}
response_headers = {
k: v for (k, v) in ttyd_response.headers.items() if k.lower() not in drop_headers
}
response = StreamingResponse(
iter([ttyd_response.content]),
status_code=ttyd_response.status_code,
headers=response_headers,
media_type=ttyd_response.headers.get("content-type"),
)
if effective_token and effective_token != cookie_token:
response.set_cookie(
key=cookie_key,
value=effective_token,
httponly=True,
samesite="lax",
secure=(request.url.scheme == "https"),
path=f"/api/terminal/proxy/{session_id}/",
max_age=TERMINAL_SESSION_TTL_SECONDS,
)
return response
except Exception as e:
logger.error(f"Error proxying request to ttyd: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to proxy request to ttyd: {str(e)}",
)
@router.api_route(
"/proxy/{session_id}/{proxy_path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
async def proxy_ttyd_http_assets(
session_id: str,
proxy_path: str,
request: Request,
token: Optional[str] = None,
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
effective_token = _get_session_token_from_request(request, session_id, token=token)
if not effective_token:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing session token")
if not terminal_service.verify_token(effective_token, session.token_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
path = "/" + (proxy_path or "")
query_params = list(request.query_params.multi_items())
query_params = [(k, v) for (k, v) in query_params if k != "token"]
query_string = urlencode(query_params, doseq=True) if query_params else ""
ttyd_url = f"http://127.0.0.1:{session.ttyd_port}{path}"
if query_string:
ttyd_url += f"?{query_string}"
cookie_key = f"terminal_token_{session_id}"
cookie_token = request.cookies.get(cookie_key) or request.cookies.get("terminal_token")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
ttyd_response = await client.request(
method=request.method,
url=ttyd_url,
headers={k: v for k, v in request.headers.items() if k.lower() not in {"host", "connection"}},
content=await request.body() if request.method in {"POST", "PUT", "PATCH"} else None,
follow_redirects=True,
)
drop_headers = {
"content-encoding",
"transfer-encoding",
"content-security-policy",
"x-frame-options",
"content-length",
}
response_headers = {
k: v for (k, v) in ttyd_response.headers.items() if k.lower() not in drop_headers
}
response = StreamingResponse(
iter([ttyd_response.content]),
status_code=ttyd_response.status_code,
headers=response_headers,
media_type=ttyd_response.headers.get("content-type"),
)
if effective_token and effective_token != cookie_token:
response.set_cookie(
key=cookie_key,
value=effective_token,
httponly=True,
samesite="lax",
secure=(request.url.scheme == "https"),
path=f"/api/terminal/proxy/{session_id}/",
max_age=TERMINAL_SESSION_TTL_SECONDS,
)
return response
except Exception as e:
logger.error(f"Error proxying request to ttyd: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to proxy request to ttyd: {str(e)}",
)
@router.websocket("/proxy/{session_id}/ws")
async def proxy_ttyd_websocket(
websocket: WebSocket,
session_id: str,
db_session: AsyncSession = Depends(get_db),
):
token = _get_session_token_from_websocket(websocket, session_id)
if not token:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Missing token")
return
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if not session:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Session not found")
return
if not terminal_service.verify_token(token, session.token_hash):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Invalid token")
return
now = datetime.now(timezone.utc)
expires_at = _as_utc_aware(getattr(session, "expires_at", None))
if expires_at and expires_at <= now:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Session expired")
return
subprotocol = None
proto_hdr = websocket.headers.get("sec-websocket-protocol")
if proto_hdr and "tty" in proto_hdr:
subprotocol = "tty"
await websocket.accept(subprotocol=subprotocol)
query_params = list(websocket.query_params.multi_items())
query_params = [(k, v) for (k, v) in query_params if k != "token"]
upstream_qs = urlencode(query_params, doseq=True) if query_params else ""
upstream_url = f"ws://127.0.0.1:{session.ttyd_port}/ws" + (f"?{upstream_qs}" if upstream_qs else "")
import websockets
logger.info(
json.dumps(
{
"component": "terminal",
"session_id": session_id,
"event": "ws_proxy_connect_upstream",
"upstream_url": upstream_url,
"ttyd_port": session.ttyd_port,
}
)
)
try:
# Ttyd checks Origin by default. We must provide a valid Origin matching the host/port
# or ttyd will reject the WebSocket handshake (403).
upstream_headers = {
"Origin": f"http://127.0.0.1:{session.ttyd_port}",
}
async with websockets.connect(
upstream_url,
subprotocols=["tty"],
open_timeout=5,
ping_interval=None,
max_size=None,
additional_headers=upstream_headers,
) as upstream:
logger.info(
json.dumps(
{
"component": "terminal",
"session_id": session_id,
"event": "ws_proxy_connected_upstream",
"ttyd_port": session.ttyd_port,
}
)
)
async def _client_to_upstream():
try:
while True:
message = await websocket.receive()
if message.get("type") == "websocket.disconnect":
break
data = message.get("bytes")
if data is not None:
await upstream.send(data)
continue
text_data = message.get("text")
if text_data is not None:
await upstream.send(text_data)
except WebSocketDisconnect:
pass
finally:
try:
await upstream.close()
except Exception:
pass
async def _upstream_to_client():
try:
async for data in upstream:
if isinstance(data, bytes):
await websocket.send_bytes(data)
else:
await websocket.send_text(data)
finally:
try:
await websocket.close()
except Exception:
pass
await asyncio.gather(_client_to_upstream(), _upstream_to_client(), return_exceptions=True)
except Exception as e:
logger.exception(
json.dumps(
{
"component": "terminal",
"session_id": session_id,
"event": "ws_proxy_error",
"error": str(e),
"ttyd_port": getattr(session, "ttyd_port", None),
}
)
)
try:
await websocket.close(code=1011, reason="Proxy error")
except Exception:
pass
@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),
):
if not terminal_service.check_ttyd_available():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Terminal feature unavailable: ttyd is not installed",
)
session_repo = TerminalSessionRepository(db_session)
host_repo = HostRepository(db_session)
bs_repo = BootstrapStatusRepository(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"
)
if host.status == "offline":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Host '{host.name}' is offline. Cannot open terminal."
)
bootstrap = await bs_repo.latest_for_host(host.id)
bootstrap_ok = bootstrap and bootstrap.status == "success"
if not bootstrap_ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Host '{host.name}' has not been bootstrapped. Run bootstrap first to configure SSH access."
)
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
username = current_user.get("username", "api_user")
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:
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)
remaining = _compute_remaining_seconds(expires_at)
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.netloc if http_request else "localhost")
ws_token = urlencode({"token": token})
return TerminalSessionResponse(
session_id=existing_session.id,
url=session_url,
websocket_url=f"{ws_scheme}://{ws_host}/api/terminal/proxy/{existing_session.id}/ws?{ws_token}",
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,
)
active_sessions = await session_repo.list_active_for_user(user_id)
active_count = len(active_sessions)
if active_count >= TERMINAL_MAX_SESSIONS_PER_USER:
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}")
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),
)
session_id = terminal_service.generate_session_id()
token, token_hash = terminal_service.generate_session_token()
port = None
pid = None
last_spawn_error = None
for _ in range(3):
try:
port = await terminal_service.allocate_port(session_id)
except Exception as e:
logger.error(f"Failed to allocate port: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="No available ports for terminal session",
)
try:
pid = await terminal_service.spawn_ttyd(
session_id=session_id,
host_ip=host.ip_address,
port=port,
token=token,
)
if pid is None:
last_spawn_error = "Failed to start terminal process"
await terminal_service.release_port(port)
port = None
continue
break
except TtydNotAvailableError:
await terminal_service.release_port(port)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Terminal feature unavailable: ttyd is not installed",
)
except Exception as e:
last_spawn_error = str(e)
await terminal_service.release_port(port)
port = None
continue
if pid is None or port is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start terminal: {last_spawn_error or 'unknown error'}",
)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=TERMINAL_SESSION_TTL_MINUTES)
session = None
try:
session = await session_repo.create(
id=session_id,
host_id=host.id,
host_name=host.name,
host_ip=host.ip_address,
token_hash=token_hash,
ttyd_port=port,
ttyd_pid=pid,
expires_at=expires_at,
user_id=user_id,
username=username,
mode=request.mode,
)
await db_session.commit()
except Exception as e:
logger.exception(f"Failed to create session in DB: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create terminal session"
)
finally:
if session is None:
try:
await terminal_service.terminate_session(session_id)
except Exception:
pass
try:
await terminal_service.release_port(port)
except Exception:
pass
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
ws_scheme = "wss" if (http_request and http_request.url.scheme == "https") else "ws"
ws_host = (http_request.url.netloc if http_request else "localhost")
ws_token = urlencode({"token": token})
return TerminalSessionResponse(
session_id=session_id,
url=session_url,
websocket_url=f"{ws_scheme}://{ws_host}/api/terminal/proxy/{session_id}/ws?{ws_token}",
expires_at=expires_at,
ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60,
mode=request.mode,
host=TerminalSessionHost(
id=host.id,
name=host.name,
ip=host.ip_address,
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),
):
session_repo = TerminalSessionRepository(db_session)
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
sessions = await session_repo.list_active_for_user(user_id)
now = datetime.now(timezone.utc)
session_list = []
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 = _compute_remaining_seconds(expires_at, now=now)
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(
sessions=session_list,
total=len(session_list),
max_per_user=TERMINAL_MAX_SESSIONS_PER_USER,
)
@router.delete("/sessions/{session_id}")
async def close_terminal_session(
session_id: str,
request: Request,
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if not session:
return {"message": "Session not found or already closed", "session_id": session_id}
if session.status in [SESSION_STATUS_CLOSED, "expired", "error"]:
return {"message": "Session already closed", "session_id": session_id}
token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
if not token or not terminal_service.verify_token(token, session.token_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
await terminal_service.close_session_with_cleanup(
session_id=session_id,
port=session.ttyd_port,
reason=CLOSE_REASON_USER
)
await session_repo.close_session(session_id, reason=CLOSE_REASON_USER)
await db_session.commit()
logger.info(f"session_closed session={session_id[:8]}... 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,
request: Request,
db_session: AsyncSession = Depends(get_db),
):
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"
)
token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
if not token or not terminal_service.verify_token(token, session.token_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid session token")
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 = _compute_remaining_seconds(expires_at, now=now)
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),
):
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if session and session.status == SESSION_STATUS_ACTIVE:
await terminal_service.close_session_with_cleanup(
session_id=session_id,
port=session.ttyd_port,
reason=CLOSE_REASON_CLIENT_LOST
)
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")
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),
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
active_sessions = await session_repo.list_active_for_user(user_id)
terminated = 0
closed = 0
for session in active_sessions:
try:
await terminal_service.terminate_session(session.id)
await terminal_service.release_port(session.ttyd_port)
terminated += 1
except Exception as e:
logger.warning(f"Failed to terminate session {session.id[:8]}: {e}")
try:
await session_repo.close_session(session.id, reason=CLOSE_REASON_USER)
closed += 1
except Exception as e:
logger.warning(f"Failed to close session {session.id[:8]} in DB: {e}")
await db_session.commit()
logger.info(f"Terminal cleanup: {terminated} processes terminated, {closed} sessions closed")
return {
"message": "Cleanup completed",
"processes_terminated": terminated,
"sessions_closed": closed,
"expired_marked": 0,
}
@router.post("/sessions/{session_id}/command")
async def log_terminal_command(
session_id: str,
command_data: dict,
request: Request,
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
if not token or not terminal_service.verify_token(token, session.token_hash):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid session token"
)
command = command_data.get("command")
if not command:
return {"status": "ignored", "reason": "empty_command"}
if len(command) > _MAX_TERMINAL_COMMAND_LENGTH:
command = command[:_MAX_TERMINAL_COMMAND_LENGTH]
cmd_repo = TerminalCommandLogRepository(db_session)
command_hash = hashlib.sha256(command.encode()).hexdigest()
await cmd_repo.create(
host_id=session.host_id,
host_name=session.host_name,
user_id=session.user_id,
username=session.username,
terminal_session_id=session.id,
command=command,
command_hash=command_hash,
source="terminal_interactive"
)
await db_session.commit()
return {"status": "logged", "command_len": len(command)}
@router.get("/connect/{session_id}")
async def get_terminal_connect_page(
session_id: str,
token: str,
request: Request,
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get_active_by_id(session_id)
if not session:
safe_title = "Session Expirée"
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html>
<head>
<title>{safe_title}</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; }}
.error p {{ color: #e5e7eb; margin-bottom: 0.75rem; }}
.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>Session Expirée ou Invalide</h1>
<p>Cette session terminal n'existe pas ou a expiré.</p>
<p><a href="/">Retour au Dashboard</a></p>
</div>
</body>
</html>
""",
status_code=404
)
if not terminal_service.verify_token(token, session.token_hash):
safe_title = html.escape(session.host_name or "Terminal")
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html>
<head>
<title>{safe_title}</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; }}
.error p {{ color: #e5e7eb; margin-bottom: 0.75rem; }}
.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>Accès Refusé</h1>
<p>Token de session invalide.</p>
</div>
</body>
</html>
""",
status_code=403
)
alive = True
try:
alive = await terminal_service.is_session_process_alive(session_id)
except Exception:
alive = True
if not alive:
if await _is_port_reachable(request, session.ttyd_port):
alive = True
else:
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:
safe_title = html.escape(session.host_name or "Terminal")
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html>
<head>
<title>{safe_title}</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; }}
.error p {{ color: #e5e7eb; margin-bottom: 0.75rem; }}
.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,
)
now = datetime.now(timezone.utc)
expires_at = _as_utc_aware(session.expires_at)
remaining_seconds = _compute_remaining_seconds(expires_at, now=now)
ttyd_port = session.ttyd_port
embed_mode = request.query_params.get("embed") in {"1", "true", "yes"}
safe_host_name_html = html.escape(session.host_name or "")
safe_host_ip_html = html.escape(session.host_ip or "")
safe_title = html.escape(session.host_name or "Terminal")
js_session_id = json.dumps(session_id)
js_token = json.dumps(token)
js_host_id = json.dumps(session.host_id)
js_host_name = json.dumps(session.host_name)
js_host_ip = json.dumps(session.host_ip)
history_script_block = """
// ===== HISTORY FUNCTIONS =====
let historyPanelOpen = false;
let historySelectedIndex = -1;
let historySearchQuery = '';
let historyTimeFilter = 'all';
async function toggleHistory() {
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('btnHistory');
if (historyPanelOpen) {
closeHistoryPanel();
} else {
panel.style.display = 'flex';
panel.classList.add('open');
btn.classList.add('active');
historyPanelOpen = true;
historySelectedIndex = -1;
const searchInput = document.getElementById('terminalHistorySearch');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
if (historyData.length === 0) {
loadHistory();
}
}
}
function closeHistoryPanel() {
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('btnHistory');
panel.classList.remove('open');
btn.classList.remove('active');
historyPanelOpen = false;
historySelectedIndex = -1;
setTimeout(() => {
try {
panel.style.display = 'none';
} catch (e) {
// ignore
}
}, 200);
document.getElementById('terminalFrame').focus();
}
async function loadHistory() {
const list = document.getElementById('terminalHistoryList');
list.innerHTML = '<div class="terminal-history-loading"><i class="fas fa-spinner fa-spin"></i> Chargement...</div>';
const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false;
const query = historySearchQuery;
let endpoint;
if (allHosts) {
endpoint = '/api/terminal/command-history?limit=100';
} else {
endpoint = `/api/terminal/${HOST_ID}/shell-history?limit=100`;
}
if (query) {
endpoint += (endpoint.includes('?') ? '&' : '?') + 'query=' + encodeURIComponent(query);
}
try {
const res = await fetch(endpoint, {
headers: { 'Authorization': 'Bearer ' + TOKEN }
});
const data = await res.json();
let commands = data.commands || [];
// Client-side time filtering
if (historyTimeFilter !== 'all') {
const now = new Date();
let cutoff;
switch (historyTimeFilter) {
case 'today':
cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate());
break;
case 'week':
cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case 'month':
cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
}
if (cutoff) {
commands = commands.filter(cmd => {
const cmdDate = new Date(cmd.last_used || cmd.created_at);
return cmdDate >= cutoff;
});
}
}
historyData = commands;
historySelectedIndex = -1;
renderHistory();
} catch (e) {
list.innerHTML = '<div class="terminal-history-empty"><i class="fas fa-exclamation-circle"></i> Erreur de chargement</div>';
}
}
function escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&');
}
function formatRelativeTime(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "À l'instant";
if (diffMin < 60) return `Il y a ${diffMin}min`;
if (diffHour < 24) return `Il y a ${diffHour}h`;
if (diffDay < 7) return `Il y a ${diffDay}j`;
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
}
function renderHistory() {
const list = document.getElementById('terminalHistoryList');
const query = historySearchQuery || '';
if (historyData.length === 0) {
const emptyMessage = query
? `Aucun résultat pour "${escapeHtml(query)}"`
: 'Aucune commande dans l historique';
list.innerHTML = `
<div class="terminal-history-empty">
<i class="fas fa-terminal"></i>
<span>${emptyMessage}</span>
</div>
`;
return;
}
const items = historyData.map((cmd, index) => {
const command = cmd.command || '';
const timeAgo = formatRelativeTime(cmd.last_used || cmd.created_at);
const execCount = cmd.execution_count || 1;
const isSelected = index === historySelectedIndex;
let displayCommand = escapeHtml(command.length > 60 ? command.substring(0, 60) + '...' : command);
if (query) {
const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi');
displayCommand = displayCommand.replace(regex, '<mark>$1</mark>');
}
return `
<div class="terminal-history-item${isSelected ? ' selected' : ''}"
data-index="${index}"
onclick="selectHistoryCommand(${index})"
ondblclick="executeHistoryCommand(${index})"
title="${escapeHtml(command)}">
<div class="terminal-history-cmd">
<code>${displayCommand}</code>
</div>
<div class="terminal-history-meta">
<span class="terminal-history-time">${timeAgo}</span>
${execCount > 1 ? `<span class="terminal-history-count">×${execCount}</span>` : ''}
</div>
<div class="terminal-history-actions-inline">
<button class="terminal-history-action" onclick="event.stopPropagation(); copyHistoryCommand(${index})" title="Copier">
<i class="fas fa-copy"></i>
</button>
<button class="terminal-history-action terminal-history-action-execute" onclick="event.stopPropagation(); executeHistoryCommand(${index})" title="Exécuter">
<i class="fas fa-play"></i>
</button>
</div>
</div>
`;
}).join('');
list.innerHTML = items;
if (historySelectedIndex >= 0) {
const selectedEl = list.querySelector('.terminal-history-item.selected');
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}
function handleHistoryKeydown(event) {
const key = event.key;
const historyLength = historyData.length;
switch (key) {
case 'ArrowDown':
event.preventDefault();
if (historyLength > 0) {
historySelectedIndex = Math.min(historySelectedIndex + 1, historyLength - 1);
renderHistory();
}
break;
case 'ArrowUp':
event.preventDefault();
if (historyLength > 0) {
historySelectedIndex = Math.max(historySelectedIndex - 1, 0);
renderHistory();
}
break;
case 'Enter':
event.preventDefault();
if (historySelectedIndex >= 0) {
selectHistoryCommand(historySelectedIndex);
} else if (historyLength > 0) {
selectHistoryCommand(0);
}
break;
case 'Escape':
event.preventDefault();
closeHistoryPanel();
break;
}
}
let searchTimeout = null;
function searchHistory(query) {
historySearchQuery = query;
historySelectedIndex = -1;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => loadHistory(), 250);
}
function clearHistorySearch() {
const input = document.getElementById('terminalHistorySearch');
if (input) { input.value = ''; input.focus(); }
historySearchQuery = '';
historySelectedIndex = -1;
loadHistory();
}
function setHistoryTimeFilter(value) {
historyTimeFilter = value;
historySelectedIndex = -1;
loadHistory();
}
function toggleHistoryScope() {
historySelectedIndex = -1;
loadHistory();
}
function showNotification(message) {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed; bottom: 20px; right: 20px;
background: rgba(124, 58, 237, 0.9); color: white;
padding: 0.75rem 1rem; border-radius: 0.5rem;
font-size: 0.875rem; z-index: 9999;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transition = 'opacity 0.3s';
setTimeout(() => notification.remove(), 300);
}, 2500);
}
async function logCommand(command) {
try {
const cmd = String(command ?? '').trim();
if (!cmd) return;
await fetch('/api/terminal/sessions/' + encodeURIComponent(SESSION_ID) + '/command?token=' + encodeURIComponent(TOKEN), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: cmd })
});
} catch (e) {
// Best-effort
}
}
function selectHistoryCommand(index) {
const cmd = historyData[index];
if (!cmd) return;
copyTextToClipboard(cmd.command || '')
.then(() => {
showNotification('Commande copiée - Collez avec Ctrl+Shift+V');
closeHistoryPanel();
})
.catch(() => showNotification('Commande: ' + (cmd.command || '')));
}
function executeHistoryCommand(index) {
if (!Array.isArray(historyData)) {
console.warn('historyData nest pas un tableau');
return;
}
if (typeof index !== 'number' || index < 0 || index >= historyData.length) {
console.warn('Index invalide:', index);
return;
}
const cmd = historyData[index];
if (!cmd || typeof cmd.command !== 'string' || !cmd.command.trim()) {
showNotification('Commande invalide ou vide');
return;
}
const commandText = cmd.command.trim() + '\\n';
copyTextToClipboard(commandText)
.then(() => {
showNotification('Commande copiée — collez pour exécuter');
logCommand(cmd.command);
closeHistoryPanel();
})
.catch((err) => {
console.error('Erreur copie presse-papiers:', err);
showNotification('Impossible de copier la commande');
});
}
function copyHistoryCommand(index) {
const cmd = historyData[index];
if (!cmd) return;
copyTextToClipboard(cmd.command || '')
.then(() => {
showNotification('Commande copiée');
logCommand(cmd.command || '');
})
.catch(() => showNotification('Impossible de copier'));
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (historyPanelOpen) {
closeHistoryPanel();
}
}
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
e.preventDefault();
toggleHistory();
}
});
"""
script_block = (
"<script>\n"
f" const SESSION_ID = {js_session_id};\n"
f" const TOKEN = {js_token};\n"
f" const HOST_ID = {js_host_id};\n"
f" const HOST_NAME = {js_host_name};\n"
f" const HOST_IP = {js_host_ip};\n"
f" let remainingSeconds = {remaining_seconds};\n"
f" const EMBED = {str(embed_mode).lower()};\n"
f" const HEARTBEAT_INTERVAL_MS = {TERMINAL_HEARTBEAT_INTERVAL_SECONDS * 1000};\n"
" let historyData = [];\n"
" let heartbeatTimer = null;\n"
" let iframeLoaded = false;\n"
" let ttydUrl = null;\n"
" let debugVisible = false;\n\n"
" function toggleDebug(force) {\n"
" debugVisible = typeof force === 'boolean' ? force : !debugVisible;\n"
" const el = document.getElementById('terminalDebug');\n"
" if (el) el.style.display = debugVisible ? 'block' : 'none';\n"
" }\n\n"
" async function updateDebug(reason) {\n"
" const body = document.getElementById('terminalDebugBody');\n"
" if (!body) return;\n\n"
" try {\n"
" const probeUrl = '/api/terminal/sessions/' + encodeURIComponent(SESSION_ID) + '/probe?token=' + encodeURIComponent(TOKEN);\n"
" const res = await fetch(probeUrl);\n"
" if (!res.ok) {\n"
" const errText = await res.text();\n"
" body.textContent = 'reason: ' + (reason || '') + '\\nprobe_error: ' + res.status + ' ' + (errText.substring(0, 100) || 'no response');\n"
" toggleDebug(true);\n"
" return;\n"
" }\n"
" const data = await res.json();\n"
" body.textContent = [\n"
" 'reason: ' + (reason || ''),\n"
" 'page: ' + window.location.href,\n"
" 'computed_ttyd_url: ' + (ttydUrl || '(non définie)'),\n"
" 'probe.ttyd_port: ' + (data.ttyd_port ?? 'n/a'),\n"
" 'probe.process_alive: ' + String(data.process_alive),\n"
" 'probe.port_reachable: ' + String(data.port_reachable),\n"
" 'probe.ttyd_interface: ' + (data.ttyd_interface || '(unset)'),\n"
" 'probe.dashboard_host: ' + (data.dashboard_host || '(n/a)'),\n"
" 'probe.dashboard_scheme: ' + (data.dashboard_scheme || '(n/a)')\n"
" ].join('\\n');\n"
" toggleDebug(true);\n"
" } catch (e) {\n"
" body.textContent = 'reason: ' + (reason || '') + '\\nerror: ' + (e && e.message ? e.message : String(e));\n"
" toggleDebug(true);\n"
" }\n"
" }\n\n"
" window.addEventListener('keydown', (e) => {\n"
" if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'D' || e.key === 'd')) {\n"
" e.preventDefault();\n"
" updateDebug('manual');\n"
" }\n"
" });\n\n"
" window.addEventListener('error', (event) => {\n"
" const body = document.getElementById('terminalDebugBody');\n"
" if (!body) return;\n"
" body.textContent += '\\njs_error: ' + (event && event.message ? event.message : 'unknown');\n"
" if (event && event.filename) body.textContent += '\\njs_error_file: ' + event.filename;\n"
" if (event && event.lineno) body.textContent += '\\njs_error_line: ' + event.lineno;\n"
" toggleDebug(true);\n"
" });\n\n"
" window.addEventListener('unhandledrejection', (event) => {\n"
" const body = document.getElementById('terminalDebugBody');\n"
" if (!body) return;\n"
" const reason = event && event.reason ? event.reason : null;\n"
" body.textContent += '\\nunhandled_rejection: ' + (reason && reason.message ? reason.message : String(reason));\n"
" toggleDebug(true);\n"
" });\n\n"
" (function setTerminalSrc() {\n"
f" const port = {ttyd_port};\n"
" const host = window.location.host;\n"
" const frame = document.getElementById('terminalFrame');\n"
" toggleDebug(true);\n"
" ttydUrl = window.location.protocol + '//' + host + '/api/terminal/proxy/' + SESSION_ID + '/?token=' + encodeURIComponent(TOKEN) + '&_t=' + Date.now();\n"
" const debugBody = document.getElementById('terminalDebugBody');\n"
" if (debugBody) {\n"
" debugBody.textContent += '\\ncomputed_ttyd_url (via proxy): ' + ttydUrl;\n"
" debugBody.textContent += '\\nSESSION_ID: ' + SESSION_ID;\n"
" debugBody.textContent += '\\nTOKEN: (masked)';\n"
" debugBody.textContent += '\\nttyd_port (from template): ' + port;\n"
" debugBody.textContent += '\\nwindow.location.port: ' + window.location.port;\n"
" debugBody.textContent += '\\nwindow.location.host: ' + host;\n"
" }\n"
" setTimeout(() => {\n"
" updateDebug('immediate_boot');\n"
" }, 100);\n"
" frame.addEventListener('load', () => {\n"
" iframeLoaded = true;\n"
" const loading = document.getElementById('terminalLoading');\n"
" if (loading) loading.classList.add('hidden');\n"
" try { frame.focus(); } catch (e) {}\n"
" });\n"
" frame.addEventListener('error', () => {\n"
" updateDebug('iframe_error');\n"
" });\n"
" const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';\n"
" const wsProxyUrl = wsScheme + '://' + host + '/api/terminal/proxy/' + SESSION_ID + '/ws?token=' + encodeURIComponent(TOKEN);\n"
" if (debugBody) {\n"
" debugBody.textContent += '\\nws_proxy_url: ' + wsProxyUrl;\n"
" }\n"
" frame.src = ttydUrl;\n"
" })();\n\n"
" const iframeTimeoutMs = 8000;\n"
" setTimeout(() => {\n"
" if (iframeLoaded) return;\n"
" const loading = document.getElementById('terminalLoading');\n"
" if (loading) {\n"
" loading.classList.remove('hidden');\n"
" loading.innerHTML = `\n"
" <div style=\"text-align:center; max-width:520px;\">\n"
" <div style=\"font-size:1.1rem; font-weight:600; color:#ef4444; margin-bottom:0.5rem;\">Terminal injoignable</div>\n"
" <div style=\"color:#e5e7eb; margin-bottom:0.75rem;\">Le navigateur n'a reçu aucune réponse du service ttyd.</div>\n"
" <div style=\"color:#9ca3af; font-size:0.85rem; margin-bottom:0.75rem;\">URL: ${ttydUrl || '(non définie)'} </div>\n"
" <div style=\"display:flex; justify-content:center; gap:0.5rem;\">\n"
" <button class=\"btn btn-secondary\" onclick=\"reconnect()\">Reconnecter</button>\n"
" <button class=\"btn btn-danger\" onclick=\"goToDashboard()\">Fermer</button>\n"
" </div>\n"
" </div>\n"
" `;\n"
" }\n"
" updateDebug('iframe_timeout');\n"
" }, iframeTimeoutMs);\n\n"
" function updateTimer() {\n"
" remainingSeconds--;\n"
" if (remainingSeconds <= 0) {\n"
" document.getElementById('timerDisplay').textContent = 'Expiré';\n"
" document.getElementById('sessionTimer').classList.add('critical');\n"
" return;\n"
" }\n"
" const minutes = Math.floor(remainingSeconds / 60);\n"
" const seconds = remainingSeconds % 60;\n"
" document.getElementById('timerDisplay').textContent = minutes + ':' + seconds.toString().padStart(2, '0');\n"
" const timer = document.getElementById('sessionTimer');\n"
" if (remainingSeconds < 60) {\n"
" timer.classList.add('critical');\n"
" timer.classList.remove('warning');\n"
" } else if (remainingSeconds < 300) {\n"
" timer.classList.add('warning');\n"
" }\n"
" }\n"
" setInterval(updateTimer, 1000);\n\n"
" async function sendHeartbeat() {\n"
" if (EMBED) return;\n"
" try {\n"
" const headers = { 'Content-Type': 'application/json' };\n"
" headers['Authorization'] = 'Bearer ' + TOKEN;\n"
" await fetch('/api/terminal/sessions/' + SESSION_ID + '/heartbeat', {\n"
" method: 'POST',\n"
" headers,\n"
" body: JSON.stringify({})\n"
" });\n"
" } catch (e) {}\n"
" }\n\n"
" function startHeartbeat() {\n"
" if (EMBED) return;\n"
" if (heartbeatTimer) return;\n"
" sendHeartbeat();\n"
" heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);\n"
" }\n\n"
" async function copyTextToClipboard(text) {\n"
" const value = String(text ?? '');\n"
" if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {\n"
" try {\n"
" await navigator.clipboard.writeText(value);\n"
" return;\n"
" } catch (e) {}\n"
" }\n"
" const ta = document.createElement('textarea');\n"
" ta.value = value;\n"
" ta.setAttribute('readonly', '');\n"
" ta.style.position = 'fixed';\n"
" ta.style.left = '-9999px';\n"
" ta.style.top = '0';\n"
" document.body.appendChild(ta);\n"
" ta.select();\n"
" const ok = document.execCommand('copy');\n"
" document.body.removeChild(ta);\n"
" if (!ok) {\n"
" throw new Error('copy_failed');\n"
" }\n"
" }\n\n"
" function goToDashboard() {\n"
" if (EMBED) {\n"
" try { window.parent.postMessage({ type: 'terminal:closeDrawer' }, '*'); } catch (e) {}\n"
" return;\n"
" }\n"
" if (window.opener) {\n"
" window.close();\n"
" } else {\n"
" window.location.href = '/';\n"
" }\n"
" }\n\n"
" function reconnect() {\n"
" if (EMBED) {\n"
" try { window.parent.postMessage({ type: 'terminal:reconnect' }, '*'); } catch (e) {}\n"
" return;\n"
" }\n"
" try { document.getElementById('terminalLoading').classList.remove('hidden'); } catch (e) {}\n"
" const url = new URL(window.location.href);\n"
" url.searchParams.set('_ts', Date.now().toString());\n"
" window.location.replace(url.toString());\n"
" }\n\n"
" function closeSession() {\n"
" if (confirm('Fermer cette session terminal?')) {\n"
" fetch('/api/terminal/sessions/' + SESSION_ID, {\n"
" method: 'DELETE',\n"
" headers: { 'Authorization': 'Bearer ' + TOKEN }\n"
" }).then(() => { goToDashboard(); }).catch(() => { goToDashboard(); });\n"
" }\n"
" }\n\n"
" function copySSHCommand() {\n"
" const cmd = 'ssh automation@' + HOST_IP;\n"
" copyTextToClipboard(cmd).catch(() => {});\n"
" }\n\n"
" function dismissPwaHint() {\n"
" document.getElementById('pwaHint').classList.add('hidden');\n"
" localStorage.setItem('pwaHintDismissed', 'true');\n"
" }\n"
" if (localStorage.getItem('pwaHintDismissed') === 'true' || window.matchMedia('(display-mode: standalone)').matches) {\n"
" document.getElementById('pwaHint').classList.add('hidden');\n"
" }\n"
"\n"
+ history_script_block
+ "\n"
" startHeartbeat();\n"
"</script>"
)
html_content = f"""
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{safe_title}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0f0f1a;
color: #e5e7eb;
height: 100vh;
display: flex;
flex-direction: column;
}}
.terminal-header {{
background: #1a1a2e;
padding: 0.5rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #374151;
flex-shrink: 0;
}}
.terminal-header .host-info {{
display: flex;
align-items: center;
gap: 0.75rem;
}}
.terminal-header .host-name {{
font-weight: 600;
color: #fff;
}}
.terminal-header .host-ip {{
color: #9ca3af;
font-size: 0.875rem;
}}
.terminal-header .status-badge {{
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}}
.terminal-header .status-badge.online {{
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}}
.terminal-header .session-timer {{
display: flex;
align-items: center;
gap: 0.5rem;
color: #9ca3af;
font-size: 0.875rem;
}}
.terminal-header .session-timer.warning {{
color: #fbbf24;
}}
.terminal-header .session-timer.critical {{
color: #ef4444;
}}
.terminal-header .actions {{
display: flex;
gap: 0.5rem;
}}
.terminal-header .btn {{
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
transition: all 0.15s;
}}
.terminal-header .btn-secondary {{
background: #374151;
color: #e5e7eb;
}}
.terminal-header .btn-secondary:hover {{
background: #4b5563;
}}
.terminal-header .btn-secondary.active {{
background: #4f46e5;
color: #fff;
}}
.terminal-header .btn-danger {{
background: #dc2626;
color: #fff;
}}
.terminal-header .btn-danger:hover {{
background: #b91c1c;
}}
.terminal-container {{
flex: 1;
position: relative;
overflow: hidden;
}}
.terminal-container iframe {{
width: 100%;
height: 100%;
border: none;
background: #000;
}}
.terminal-loading {{
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #0f0f1a;
}}
.terminal-loading .spinner {{
width: 48px;
height: 48px;
border: 3px solid #374151;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}}
@keyframes spin {{
to {{ transform: rotate(360deg); }}
}}
.terminal-loading .loading-text {{
margin-top: 1rem;
color: #9ca3af;
}}
.pwa-hint {{
background: #1e3a5f;
color: #93c5fd;
padding: 0.5rem 1rem;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
}}
.pwa-hint button {{
background: none;
border: none;
color: #60a5fa;
cursor: pointer;
font-size: 0.75rem;
}}
body.embed #pwaHint {{
display: none !important;
}}
body.embed .terminal-header {{
display: none !important;
}}
.hidden {{ display: none !important; }}
/* History Panel Styles - matching drawer component (dropdown from top) */
.terminal-history-panel {{
position: absolute;
top: 0;
left: 0;
right: 0;
max-height: 0;
overflow: hidden;
background: #1e1e2e;
border-bottom: 1px solid #374151;
display: flex;
flex-direction: column;
z-index: 50;
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
opacity: 0;
transition: max-height 0.3s ease, opacity 0.2s ease;
}}
.terminal-history-panel.open {{
max-height: 350px;
opacity: 1;
}}
.terminal-history-header {{
padding: 0.75rem;
background: #151520;
border-bottom: 1px solid #374151;
flex-shrink: 0;
}}
.terminal-history-search {{
position: relative;
display: flex;
align-items: center;
background: #0f0f1a;
border: 1px solid #374151;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
transition: border-color 0.15s;
}}
.terminal-history-search:focus-within {{
border-color: #7c3aed;
}}
.terminal-history-search i {{
color: #6b7280;
font-size: 0.875rem;
margin-right: 0.5rem;
}}
.terminal-history-search input {{
flex: 1;
background: transparent;
border: none;
color: #e5e7eb;
font-size: 0.875rem;
outline: none;
}}
.terminal-history-search input::placeholder {{
color: #6b7280;
}}
.terminal-history-clear-search {{
background: none;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
font-size: 0.75rem;
transition: color 0.15s;
}}
.terminal-history-clear-search:hover {{
color: #e5e7eb;
}}
.terminal-history-filters {{
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
}}
.terminal-history-filter-select {{
background: #0f0f1a;
border: 1px solid #374151;
border-radius: 0.375rem;
color: #e5e7eb;
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
cursor: pointer;
outline: none;
}}
.terminal-history-filter-select:hover,
.terminal-history-filter-select:focus {{
border-color: #7c3aed;
}}
.terminal-history-scope {{
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #9ca3af;
cursor: pointer;
}}
.terminal-history-scope input {{
width: 0.875rem;
height: 0.875rem;
accent-color: #7c3aed;
cursor: pointer;
}}
.terminal-history-list {{
flex: 1;
overflow-y: auto;
padding: 0.5rem;
scrollbar-width: thin;
scrollbar-color: #374151 transparent;
}}
.terminal-history-list::-webkit-scrollbar {{
width: 6px;
}}
.terminal-history-list::-webkit-scrollbar-track {{
background: transparent;
}}
.terminal-history-list::-webkit-scrollbar-thumb {{
background: #374151;
border-radius: 3px;
}}
.terminal-history-item {{
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
gap: 0.75rem;
border: 1px solid transparent;
}}
.terminal-history-item:hover {{
background: rgba(124, 58, 237, 0.1);
}}
.terminal-history-item.selected {{
background: rgba(124, 58, 237, 0.2);
border-color: rgba(124, 58, 237, 0.4);
}}
.terminal-history-cmd {{
flex: 1;
min-width: 0;
}}
.terminal-history-cmd code {{
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem;
color: #a5b4fc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}}
.terminal-history-cmd code mark {{
background: rgba(251, 191, 36, 0.3);
color: #fbbf24;
padding: 0 0.125rem;
border-radius: 2px;
}}
.terminal-history-meta {{
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}}
.terminal-history-time {{
font-size: 0.6875rem;
color: #6b7280;
}}
.terminal-history-count {{
font-size: 0.625rem;
color: #7c3aed;
background: rgba(124, 58, 237, 0.2);
padding: 0.125rem 0.375rem;
border-radius: 9999px;
font-weight: 500;
}}
.terminal-history-actions-inline {{
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.15s;
}}
.terminal-history-item:hover .terminal-history-actions-inline,
.terminal-history-item.selected .terminal-history-actions-inline {{
opacity: 1;
}}
.terminal-history-action {{
background: rgba(55, 65, 81, 0.5);
border: none;
color: #9ca3af;
padding: 0.375rem;
border-radius: 0.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
transition: background 0.15s, color 0.15s, transform 0.1s;
}}
.terminal-history-action:hover {{
background: rgba(124, 58, 237, 0.3);
color: #fff;
transform: scale(1.1);
}}
.terminal-history-action-execute:hover {{
background: rgba(34, 197, 94, 0.3);
color: #4ade80;
}}
.terminal-history-empty {{
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #6b7280;
gap: 0.5rem;
text-align: center;
}}
.terminal-history-empty i {{
font-size: 2rem;
opacity: 0.5;
}}
.terminal-history-loading {{
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: #9ca3af;
gap: 0.5rem;
}}
.terminal-history-footer {{
padding: 0.5rem 1rem;
background: #151520;
border-top: 1px solid #374151;
flex-shrink: 0;
}}
.terminal-history-hint {{
font-size: 0.6875rem;
color: #6b7280;
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
}}
.terminal-history-hint kbd {{
background: #374151;
color: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: inherit;
font-size: 0.625rem;
border: 1px solid #4b5563;
}}
</style>
</head>
<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>
</div>
<div class="terminal-header">
<div class="host-info">
<span class="host-name">{safe_host_name_html}</span>
<span class="host-ip">{safe_host_ip_html}</span>
<span class="status-badge online">Connecté</span>
</div>
<div class="session-timer" id="sessionTimer">
<i class="fas fa-clock"></i>
<span id="timerDisplay">{remaining_seconds // 60}:{remaining_seconds % 60:02d}</span>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="toggleHistory()" id="btnHistory" title="Historique (Ctrl+R)">
<i class="fas fa-history"></i> Historique
</button>
<button class="btn btn-secondary" onclick="copySSHCommand()" title="Copier la commande SSH">
<i class="fas fa-copy"></i> SSH
</button>
<button class="btn btn-secondary" onclick="reconnect()" title="Reconnecter">
<i class="fas fa-redo"></i> Reconnecter
</button>
<button class="btn btn-secondary" onclick="goToDashboard()" title="Retour au Dashboard">
<i class="fas fa-arrow-left"></i> Dashboard
</button>
<button class="btn btn-danger" onclick="closeSession()" title="Fermer la session">
<i class="fas fa-times"></i> Fermer
</button>
</div>
</div>
<div class="terminal-container">
<div class="terminal-loading" id="terminalLoading">
<div style="text-align: center;">
<div class="spinner"></div>
<div class="loading-text">Connexion au terminal...</div>
<div style="margin-top:0.75rem; font-size:0.85rem; color:#9ca3af;">session={session_id[:8]}… · ttyd_port={ttyd_port} · Debug: Ctrl+Shift+D</div>
</div>
</div>
<noscript>
<div style="position:absolute; top:14px; right:14px; z-index:9999; background:rgba(239,68,68,0.18); border:1px solid rgba(239,68,68,0.6); color:#fff; padding:10px 12px; border-radius:10px; font-size:12px; max-width:520px;">
JavaScript est désactivé: le terminal web ne peut pas se charger.
</div>
</noscript>
<div id="terminalDebug" style="display:block; position:absolute; bottom:14px; right:14px; z-index:9998; background:rgba(17,24,39,0.92); border:1px solid rgba(55,65,81,0.8); color:#e5e7eb; padding:10px 12px; border-radius:10px; font-size:12px; max-width:520px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:6px;">
<div style="font-weight:600;">Debug Terminal</div>
<button class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" onclick="toggleDebug(false)">Fermer</button>
</div>
<div id="terminalDebugBody" style="white-space:pre-wrap; color:#9ca3af; line-height:1.35;">boot: page_rendered
session_id: {session_id}
ttyd_port: {ttyd_port}
</div>
</div>
<iframe
id="terminalFrame"
src="about:blank"
allow="clipboard-read; clipboard-write; clipboard-write-text"
></iframe>
<div class="terminal-history-panel" id="terminalHistoryPanel" style="display: none;">
<div class="terminal-history-header">
<div class="terminal-history-search">
<i class="fas fa-search"></i>
<input type="text"
id="terminalHistorySearch"
placeholder="Rechercher... (Ctrl+R)"
oninput="searchHistory(this.value)"
onkeydown="handleHistoryKeydown(event)"
autocomplete="off">
<button class="terminal-history-clear-search" onclick="clearHistorySearch()" title="Effacer">
<i class="fas fa-times"></i>
</button>
</div>
<div class="terminal-history-filters">
<select id="terminalHistoryTimeFilter" onchange="setHistoryTimeFilter(this.value)" class="terminal-history-filter-select">
<option value="all">Tout</option>
<option value="today">Aujourd'hui</option>
<option value="week">7 jours</option>
<option value="month">30 jours</option>
</select>
<label class="terminal-history-scope">
<input type="checkbox" id="terminalHistoryAllHosts" onchange="toggleHistoryScope()">
<span>Tous hôtes</span>
</label>
</div>
</div>
<div class="terminal-history-list" id="terminalHistoryList">
<div class="terminal-history-loading">
<i class="fas fa-spinner fa-spin"></i> Chargement...
</div>
</div>
<div class="terminal-history-footer">
<span class="terminal-history-hint">
<kbd>↑</kbd><kbd>↓</kbd> naviguer · <kbd>Enter</kbd> insérer · <kbd>Esc</kbd> fermer
</span>
</div>
</div>
</div>
{script_block}
</body>
</html>
"""
return HTMLResponse(content=html_content, headers={"Cache-Control": "no-store"})
@router.get("/popout/{session_id}")
async def get_terminal_popout_page(
session_id: str,
token: str,
request: Request,
db_session: AsyncSession = Depends(get_db),
):
"""
Serve the terminal popout page (fullscreen, minimal UI).
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, 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}/shell-history")
async def get_host_shell_history(
host_id: str,
query: Optional[str] = None,
limit: int = 100,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
"""
Get shell history directly from the remote host via SSH.
This fetches the actual ~/.bash_history file from the host,
providing real-time command history.
Args:
host_id: Host ID to get history for
query: Optional search query to filter commands
limit: Maximum number of results (default 100)
Returns:
List of commands from the remote shell 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"
)
try:
# Fetch history from the remote host via SSH
commands = await shell_history_service.fetch_combined_history(
host_ip=host.ip_address,
limit=min(limit, 200),
user="automation"
)
# Apply search filter if provided
if query:
q = query.lower()
commands = [c for c in commands if q in c.get("command", "").lower()]
return {
"commands": commands,
"total": len(commands),
"host_id": host.id,
"host_name": host.name,
"source": "remote_shell",
}
except ShellHistoryError as e:
logger.warning(f"Failed to fetch shell history from {host.name}: {e}")
# Fallback to database history
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),
)
return {
"commands": unique_cmds,
"total": len(unique_cmds),
"host_id": host.id,
"host_name": host.name,
"source": "database",
"error": str(e),
}
@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}