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
2496 lines
95 KiB
Python
2496 lines
95 KiB
Python
"""
|
||
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/\"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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 n’est 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}
|