Add debug mode feature flag with environment variable parsing, UI badge indicator, secret redaction utility, and enhanced terminal session management with status checks and session limit error handling
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

This commit is contained in:
Bruno Charest 2025-12-21 17:22:36 -05:00
parent 421446bd13
commit 70c15c9b6f
16 changed files with 606 additions and 181 deletions

View File

@ -7,10 +7,29 @@ Toutes les variables d'environnement et paramètres sont centralisés ici.
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from pydantic import Field from pydantic import Field
from pydantic import field_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
def parse_env_bool(value: object, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes"}:
return True
if normalized in {"0", "false", "no"}:
return False
return default
return default
class Settings(BaseSettings): class Settings(BaseSettings):
"""Configuration de l'application Homelab Automation.""" """Configuration de l'application Homelab Automation."""
@ -113,6 +132,14 @@ class Settings(BaseSettings):
) )
log_level: str = "info" log_level: str = "info"
# === Feature Flags ===
debug_mode: bool = Field(default=False, validation_alias="DEBUG_MODE")
@field_validator("debug_mode", mode="before")
@classmethod
def _validate_debug_mode(cls, v: object) -> bool:
return parse_env_bool(v, default=False)
class Config: class Config:
env_file = ".env" env_file = ".env"
env_file_encoding = "utf-8" env_file_encoding = "utf-8"
@ -121,3 +148,4 @@ class Settings(BaseSettings):
# Instance singleton de la configuration # Instance singleton de la configuration
settings = Settings() settings = Settings()
DEBUG_MODE_ENABLED: bool = settings.debug_mode

View File

@ -130,6 +130,15 @@ async def get_current_user(
return user return user
async def require_debug_mode() -> bool:
if not settings.debug_mode:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Debug mode disabled",
)
return True
# === Dépendance: Vérification rôle admin === # === Dépendance: Vérification rôle admin ===
async def require_admin( async def require_admin(

View File

@ -85,6 +85,8 @@ class DashboardManager {
// WebSocket // WebSocket
this.ws = null; this.ws = null;
this.debugModeEnabled = false;
// Terminal SSH // Terminal SSH
this.terminalSession = null; this.terminalSession = null;
this.terminalDrawerOpen = false; this.terminalDrawerOpen = false;
@ -114,7 +116,6 @@ class DashboardManager {
this.tasksDisplayedCount = 20; this.tasksDisplayedCount = 20;
this.tasksPerPage = 20; this.tasksPerPage = 20;
this.init();
} }
async init() { async init() {
@ -136,6 +137,9 @@ class DashboardManager {
// Hide login screen if visible // Hide login screen if visible
this.hideLoginScreen(); this.hideLoginScreen();
await this.loadAppConfig();
this.setDebugBadgeVisible(this.isDebugEnabled());
// Charger les données depuis l'API // Charger les données depuis l'API
await this.loadAllData(); await this.loadAllData();
@ -149,6 +153,72 @@ class DashboardManager {
this.startRunningTasksPolling(); this.startRunningTasksPolling();
} }
async loadAppConfig() {
const prev = Boolean(this.debugModeEnabled);
try {
const cfg = await this.apiCall('/api/config');
this.debugModeEnabled = Boolean(cfg && cfg.debug_mode);
} catch (e) {
this.debugModeEnabled = false;
}
if (prev !== Boolean(this.debugModeEnabled)) {
try {
this.renderHosts();
} catch (e) {
}
}
}
isDebugEnabled() {
return Boolean(this.debugModeEnabled);
}
redactSecrets(text) {
if (text === null || text === undefined) return '';
const input = String(text);
const mask = (val) => {
if (!val) return '';
const s = String(val);
if (s.length <= 8) return '********';
return `${s.slice(0, 4)}...${s.slice(-4)}`;
};
let out = input;
out = out.replace(/(authorization\s*:\s*bearer\s+)([^\s\r\n]+)/ig, (m, p1, p2) => `${p1}${mask(p2)}`);
out = out.replace(/\b(token|access_token|api_key|apikey|secret|password)\s*[:=]\s*([^\s'"\r\n]+)/ig, (m, k, v) => `${k}=${mask(v)}`);
out = out.replace(/\b(Bearer)\s+([^\s\r\n]+)/g, (m, p1, p2) => `${p1} ${mask(p2)}`);
return out;
}
setDebugBadgeVisible(visible) {
const existing = document.getElementById('debug-mode-badge');
if (!visible) {
if (existing) existing.remove();
return;
}
if (existing) return;
const desktopNav = document.querySelector('.desktop-nav-links');
if (!desktopNav) return;
const badge = document.createElement('span');
badge.id = 'debug-mode-badge';
badge.className = 'ml-2 px-2 py-1 text-[10px] rounded bg-red-600/30 text-red-200 border border-red-500/30 font-semibold tracking-wide';
badge.textContent = 'DEBUG';
badge.title = 'Debug mode enabled';
const userMenu = desktopNav.querySelector('.group');
if (userMenu) {
desktopNav.insertBefore(badge, userMenu);
} else {
desktopNav.appendChild(badge);
}
}
setActiveNav(pageName) { setActiveNav(pageName) {
if (typeof navigateTo === 'function') { if (typeof navigateTo === 'function') {
navigateTo(pageName); navigateTo(pageName);
@ -254,6 +324,8 @@ class DashboardManager {
// Re-initialize dashboard // Re-initialize dashboard
this.hideLoginScreen(); this.hideLoginScreen();
await this.loadAppConfig();
this.setDebugBadgeVisible(this.isDebugEnabled());
await this.loadAllData(); await this.loadAllData();
this.connectWebSocket(); this.connectWebSocket();
this.startRunningTasksPolling(); this.startRunningTasksPolling();
@ -10290,16 +10362,22 @@ class DashboardManager {
category = 'task'; category = 'task';
} }
const title = type === 'success' ? 'Succès' : (type === 'warning' ? 'Avertissement' : (type === 'error' ? 'Erreur' : 'Info')); const title = type === 'success' ? 'Succès' : (type === 'warning' ? 'Avertissement' : (type === 'error' ? 'Erreur' : 'Info'));
this.apiCall('/api/alerts', { // IMPORTANT: do NOT use apiCall() here.
method: 'POST', // apiCall() handles 401 by calling showNotification(), which would create an infinite loop
body: JSON.stringify({ // when unauthenticated.
category, if (this.accessToken) {
title, fetch(`${this.apiBase}/api/alerts`, {
level: type, method: 'POST',
message: msgStr, headers: this.getAuthHeaders(),
source: 'ui' body: JSON.stringify({
}) category,
}).catch(() => {}); title,
level: type,
message: msgStr,
source: 'ui'
})
}).catch(() => {});
}
} catch (e) { } catch (e) {
// ignore // ignore
} }
@ -10465,9 +10543,26 @@ class DashboardManager {
// Terminal SSH - Web Terminal Feature // Terminal SSH - Web Terminal Feature
// ===================================================== // =====================================================
async checkTerminalFeatureStatus() { async checkTerminalFeatureStatus(force = false) {
// Limit check frequency unless forced
const now = Date.now();
if (!force && this.lastTerminalStatusCheck && (now - this.lastTerminalStatusCheck) < 10000) {
return;
}
this.lastTerminalStatusCheck = now;
try { try {
const data = await this.apiCall('/api/terminal/status'); const data = await this.apiCall('/api/terminal/status');
if (data && typeof data.debug_mode === 'boolean') {
const prev = Boolean(this.debugModeEnabled);
this.debugModeEnabled = Boolean(data.debug_mode);
this.setDebugBadgeVisible(this.isDebugEnabled());
if (prev !== Boolean(this.debugModeEnabled)) {
try {
this.renderHosts();
} catch (e) {
}
}
}
this.terminalFeatureAvailable = Boolean(data && data.available); this.terminalFeatureAvailable = Boolean(data && data.available);
return data; return data;
} catch (e) { } catch (e) {
@ -10546,13 +10641,21 @@ class DashboardManager {
} }
async openTerminalPopout(hostId, hostName, hostIp) { async openTerminalPopout(hostId, hostName, hostIp) {
// Check if terminal feature is available // Prevent multiple popouts
if (!this.terminalFeatureAvailable) { if (this.terminalPopoutOpening) {
return;
}
this.terminalPopoutOpening = true;
try {
const status = await this.checkTerminalFeatureStatus(); const status = await this.checkTerminalFeatureStatus();
if (!status.available) { if (!status.available) {
this.showNotification('Terminal SSH non disponible: ttyd n\'est pas installé', 'error'); this.showNotification('Terminal SSH non disponible: ttyd n\'est pas installé', 'error');
return; return;
} }
} catch (e) {
console.error('Failed to check terminal feature status:', e);
this.showNotification('Erreur terminal: impossible de vérifier la disponibilité', 'error');
return;
} }
this.showNotification('Création de la session terminal...', 'info'); this.showNotification('Création de la session terminal...', 'info');
@ -11503,6 +11606,10 @@ class DashboardManager {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
console.log('Creating DashboardManager...'); console.log('Creating DashboardManager...');
window.dashboard = new DashboardManager(); window.dashboard = new DashboardManager();
window.DashboardManager = DashboardManager;
if (window.dashboard && typeof window.dashboard.init === 'function') {
window.dashboard.init();
}
console.log('DashboardManager created. Methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(dashboard)).filter(m => m.includes('Schedule'))); console.log('DashboardManager created. Methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(dashboard)).filter(m => m.includes('Schedule')));
// Allow embedded terminal connect page to request closing the terminal drawer. // Allow embedded terminal connect page to request closing the terminal drawer.

View File

@ -27,6 +27,7 @@ from app.routes.alerts import router as alerts_router
from app.routes.docker import router as docker_router from app.routes.docker import router as docker_router
from app.routes.lint import router as lint_router from app.routes.lint import router as lint_router
from app.routes.terminal import router as terminal_router from app.routes.terminal import router as terminal_router
from app.routes.config import router as config_router
# Router principal qui agrège tous les sous-routers # Router principal qui agrège tous les sous-routers
api_router = APIRouter() api_router = APIRouter()
@ -52,6 +53,7 @@ api_router.include_router(alerts_router, prefix="/alerts", tags=["Alerts"])
api_router.include_router(docker_router, prefix="/docker", tags=["Docker"]) api_router.include_router(docker_router, prefix="/docker", tags=["Docker"])
api_router.include_router(lint_router, prefix="/playbooks", tags=["Lint"]) api_router.include_router(lint_router, prefix="/playbooks", tags=["Lint"])
api_router.include_router(terminal_router, prefix="/terminal", tags=["Terminal"]) api_router.include_router(terminal_router, prefix="/terminal", tags=["Terminal"])
api_router.include_router(config_router, tags=["Config"])
__all__ = [ __all__ = [
"api_router", "api_router",

12
app/routes/config.py Normal file
View File

@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.core.config import settings
router = APIRouter()
@router.get("/config")
async def get_app_config():
return {
"debug_mode": bool(settings.debug_mode),
}

View File

@ -24,7 +24,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSo
from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.responses import HTMLResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, get_current_user from app.core.config import settings
from app.core.dependencies import get_db, get_current_user, require_debug_mode
from app.crud.host import HostRepository from app.crud.host import HostRepository
from app.crud.bootstrap_status import BootstrapStatusRepository from app.crud.bootstrap_status import BootstrapStatusRepository
from app.crud.terminal_session import TerminalSessionRepository from app.crud.terminal_session import TerminalSessionRepository
@ -111,6 +112,56 @@ def _get_session_token_from_headers(auth_header: Optional[str]) -> Optional[str]
parts = auth_header.split(" ", 1) parts = auth_header.split(" ", 1)
return parts[1].strip() if len(parts) == 2 else None return parts[1].strip() if len(parts) == 2 else None
def _build_session_limit_error(active_sessions, same_host_session=None) -> dict:
now = datetime.now(timezone.utc)
infos = []
for s in (active_sessions or []):
created_at = _as_utc_aware(getattr(s, "created_at", None))
last_seen_at = _as_utc_aware(getattr(s, "last_seen_at", None))
age_seconds = int(max(0, (now - created_at).total_seconds())) if created_at else 0
last_seen_seconds = int(max(0, (now - last_seen_at).total_seconds())) if last_seen_at else 0
infos.append(
ActiveSessionInfo(
session_id=str(getattr(s, "id", "")),
host_id=str(getattr(s, "host_id", "")),
host_name=str(getattr(s, "host_name", "")),
mode=str(getattr(s, "mode", "")),
age_seconds=age_seconds,
last_seen_seconds=last_seen_seconds,
)
)
infos.sort(key=lambda x: x.last_seen_seconds, reverse=True)
suggested_actions = []
can_reuse = False
reusable_session_id = None
if same_host_session is not None:
can_reuse = True
reusable_session_id = str(getattr(same_host_session, "id", ""))
if reusable_session_id:
suggested_actions.append("reuse_existing")
suggested_actions.append(f"reuse_session:{reusable_session_id}")
suggested_actions.append("close_oldest")
if infos:
suggested_actions.append(f"close_session:{infos[-1].session_id}")
err = SessionLimitError(
message="Maximum active terminal sessions reached",
max_active=TERMINAL_MAX_SESSIONS_PER_USER,
current_count=len(active_sessions or []),
active_sessions=infos,
suggested_actions=suggested_actions,
can_reuse=can_reuse,
reusable_session_id=reusable_session_id,
)
return err.model_dump()
def _get_session_token_from_request(request: Request, session_id: str, token: Optional[str] = None) -> Optional[str]: def _get_session_token_from_request(request: Request, session_id: str, token: Optional[str] = None) -> Optional[str]:
if token: if token:
return token return token
@ -149,6 +200,7 @@ async def get_terminal_status(current_user: dict = Depends(get_current_user)):
available = bool(terminal_service.check_ttyd_available()) available = bool(terminal_service.check_ttyd_available())
return { return {
"available": available, "available": available,
"debug_mode": bool(settings.debug_mode),
} }
@router.get("/sessions/{session_id}/probe") @router.get("/sessions/{session_id}/probe")
@ -156,6 +208,7 @@ async def probe_terminal_session(
session_id: str, session_id: str,
token: str, token: str,
request: Request, request: Request,
debug_enabled: bool = Depends(require_debug_mode),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
): ):
session_repo = TerminalSessionRepository(db_session) session_repo = TerminalSessionRepository(db_session)
@ -483,8 +536,8 @@ async def proxy_ttyd_websocket(
@router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse) @router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse)
async def create_terminal_session( async def create_terminal_session(
host_id: str, host_id: str,
session_request: TerminalSessionRequest,
http_request: Request, http_request: Request,
request: TerminalSessionRequest = TerminalSessionRequest(),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
): ):
@ -528,7 +581,7 @@ async def create_terminal_session(
existing_session = await session_repo.find_reusable_session( existing_session = await session_repo.find_reusable_session(
user_id=user_id, user_id=user_id,
host_id=host.id, host_id=host.id,
mode=request.mode, mode=session_request.mode,
idle_timeout_seconds=TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS, idle_timeout_seconds=TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS,
) )
@ -548,7 +601,7 @@ async def create_terminal_session(
connect_path = f"/api/terminal/connect/{existing_session.id}?token={token}" connect_path = f"/api/terminal/connect/{existing_session.id}?token={token}"
popout_path = f"/api/terminal/popout/{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 session_url = popout_path if session_request.mode == "popout" else connect_path
ws_scheme = "wss" if (http_request and http_request.url.scheme == "https") else "ws" 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_host = (http_request.url.netloc if http_request else "localhost")
@ -648,7 +701,7 @@ async def create_terminal_session(
expires_at=expires_at, expires_at=expires_at,
user_id=user_id, user_id=user_id,
username=username, username=username,
mode=request.mode, mode=session_request.mode,
) )
await db_session.commit() await db_session.commit()
except Exception as e: except Exception as e:
@ -673,7 +726,7 @@ async def create_terminal_session(
connect_path = f"/api/terminal/connect/{session_id}?token={token}" connect_path = f"/api/terminal/connect/{session_id}?token={token}"
popout_path = f"/api/terminal/popout/{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 session_url = popout_path if session_request.mode == "popout" else connect_path
ws_scheme = "wss" if (http_request and http_request.url.scheme == "https") else "ws" 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_host = (http_request.url.netloc if http_request else "localhost")
@ -685,7 +738,7 @@ async def create_terminal_session(
websocket_url=f"{ws_scheme}://{ws_host}/api/terminal/proxy/{session_id}/ws?{ws_token}", websocket_url=f"{ws_scheme}://{ws_host}/api/terminal/proxy/{session_id}/ws?{ws_token}",
expires_at=expires_at, expires_at=expires_at,
ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60, ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60,
mode=request.mode, mode=session_request.mode,
host=TerminalSessionHost( host=TerminalSessionHost(
id=host.id, id=host.id,
name=host.name, name=host.name,
@ -697,51 +750,11 @@ async def create_terminal_session(
token=token, 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}") @router.delete("/sessions/{session_id}")
async def close_terminal_session( async def close_terminal_session(
session_id: str, session_id: str,
request: Request, request: Request,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
): ):
session_repo = TerminalSessionRepository(db_session) session_repo = TerminalSessionRepository(db_session)
@ -774,6 +787,7 @@ async def close_terminal_session(
async def heartbeat_terminal_session( async def heartbeat_terminal_session(
session_id: str, session_id: str,
request: Request, request: Request,
token: Optional[str] = None,
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
): ):
session_repo = TerminalSessionRepository(db_session) session_repo = TerminalSessionRepository(db_session)
@ -808,6 +822,7 @@ async def heartbeat_terminal_session(
async def close_beacon_terminal_session( async def close_beacon_terminal_session(
session_id: str, session_id: str,
request: Request, request: Request,
token: Optional[str] = None,
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
): ):
session_repo = TerminalSessionRepository(db_session) session_repo = TerminalSessionRepository(db_session)
@ -830,6 +845,7 @@ async def close_beacon_terminal_session(
@router.post("/cleanup") @router.post("/cleanup")
async def cleanup_terminal_sessions( async def cleanup_terminal_sessions(
debug_enabled: bool = Depends(require_debug_mode),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
): ):
@ -870,6 +886,7 @@ async def log_terminal_command(
session_id: str, session_id: str,
command_data: dict, command_data: dict,
request: Request, request: Request,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
): ):
session_repo = TerminalSessionRepository(db_session) session_repo = TerminalSessionRepository(db_session)
@ -1425,20 +1442,23 @@ function executeHistoryCommand(index) {
}); });
""" """
script_block = ( debug_mode_enabled = bool(settings.debug_mode)
"<script>\n" debug_panel_html = (
f" const SESSION_ID = {js_session_id};\n" """
f" const TOKEN = {js_token};\n" <div id=\"terminalDebug\" style=\"display:none; 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;\">
f" const HOST_ID = {js_host_id};\n" <div style=\"display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:6px;\">
f" const HOST_NAME = {js_host_name};\n" <div style=\"font-weight:600;\">Debug Terminal</div>
f" const HOST_IP = {js_host_ip};\n" <button class=\"btn btn-secondary\" style=\"padding:0.2rem 0.5rem; font-size:0.75rem;\" onclick=\"toggleDebug(false)\">Fermer</button>
f" let remainingSeconds = {remaining_seconds};\n" </div>
f" const EMBED = {str(embed_mode).lower()};\n" <div id=\"terminalDebugBody\" style=\"white-space:pre-wrap; color:#9ca3af; line-height:1.35;\">boot: page_rendered\nsession_id: {session_id}\nttyd_port: {ttyd_port}\n</div>
f" const HEARTBEAT_INTERVAL_MS = {TERMINAL_HEARTBEAT_INTERVAL_SECONDS * 1000};\n" </div>
" let historyData = [];\n" """.format(session_id=html.escape(session_id), ttyd_port=ttyd_port)
" let heartbeatTimer = null;\n" if debug_mode_enabled
" let iframeLoaded = false;\n" else ""
" let ttydUrl = null;\n" )
debug_js = (
"" if not debug_mode_enabled else
" let debugVisible = false;\n\n" " let debugVisible = false;\n\n"
" function toggleDebug(force) {\n" " function toggleDebug(force) {\n"
" debugVisible = typeof force === 'boolean' ? force : !debugVisible;\n" " debugVisible = typeof force === 'boolean' ? force : !debugVisible;\n"
@ -1481,94 +1501,23 @@ function executeHistoryCommand(index) {
" updateDebug('manual');\n" " updateDebug('manual');\n"
" }\n" " }\n"
" });\n\n" " });\n\n"
" window.addEventListener('error', (event) => {\n" )
" const body = document.getElementById('terminalDebugBody');\n"
" if (!body) return;\n" script_block = (
" body.textContent += '\\njs_error: ' + (event && event.message ? event.message : 'unknown');\n" "<script>\n"
" if (event && event.filename) body.textContent += '\\njs_error_file: ' + event.filename;\n" f" const SESSION_ID = {js_session_id};\n"
" if (event && event.lineno) body.textContent += '\\njs_error_line: ' + event.lineno;\n" f" const TOKEN = {js_token};\n"
" toggleDebug(true);\n" f" const HOST_ID = {js_host_id};\n"
" });\n\n" f" const HOST_NAME = {js_host_name};\n"
" window.addEventListener('unhandledrejection', (event) => {\n" f" const HOST_IP = {js_host_ip};\n"
" const body = document.getElementById('terminalDebugBody');\n" f" let remainingSeconds = {remaining_seconds};\n"
" if (!body) return;\n" f" const EMBED = {str(embed_mode).lower()};\n"
" const reason = event && event.reason ? event.reason : null;\n" f" const HEARTBEAT_INTERVAL_MS = {TERMINAL_HEARTBEAT_INTERVAL_SECONDS * 1000};\n"
" body.textContent += '\\nunhandled_rejection: ' + (reason && reason.message ? reason.message : String(reason));\n" " let historyData = [];\n"
" toggleDebug(true);\n" " let heartbeatTimer = null;\n"
" });\n\n" " let iframeLoaded = false;\n"
" (function setTerminalSrc() {\n" " let ttydUrl = null;\n\n"
f" const port = {ttyd_port};\n" f"{debug_js}"
" 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" " async function sendHeartbeat() {\n"
" if (EMBED) return;\n" " if (EMBED) return;\n"
" try {\n" " try {\n"
@ -1650,6 +1599,42 @@ function executeHistoryCommand(index) {
" document.getElementById('pwaHint').classList.add('hidden');\n" " document.getElementById('pwaHint').classList.add('hidden');\n"
" }\n" " }\n"
"\n" "\n"
" (function initTerminalIframe() {\n"
" const frame = document.getElementById('terminalFrame');\n"
" const loading = document.getElementById('terminalLoading');\n"
" if (!frame) return;\n"
"\n"
" frame.addEventListener('load', () => {\n"
" iframeLoaded = true;\n"
" if (loading) loading.classList.add('hidden');\n"
" });\n"
"\n"
" const qs = new URLSearchParams();\n"
" qs.set('token', TOKEN);\n"
" ttydUrl = '/api/terminal/proxy/' + encodeURIComponent(SESSION_ID) + '?' + qs.toString();\n"
" frame.src = ttydUrl;\n"
"\n"
" const iframeTimeoutMs = 8000;\n"
" setTimeout(() => {\n"
" if (iframeLoaded) return;\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"
" if (typeof updateDebug === 'function') updateDebug('iframe_timeout');\n"
" }, iframeTimeoutMs);\n"
" })();\n"
"\n"
+ history_script_block + history_script_block
+ "\n" + "\n"
" startHeartbeat();\n" " startHeartbeat();\n"
@ -2110,16 +2095,7 @@ function executeHistoryCommand(index) {
JavaScript est désactivé: le terminal web ne peut pas se charger. JavaScript est désactivé: le terminal web ne peut pas se charger.
</div> </div>
</noscript> </noscript>
<div id="terminalDebug" style="display:none; 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;"> {debug_panel_html}
<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 <iframe
id="terminalFrame" id="terminalFrame"
src="about:blank" src="about:blank"
@ -2201,6 +2177,7 @@ async def get_host_command_history(
offset: int = 0, offset: int = 0,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
): ):
""" """
Get command history for a specific host. Get command history for a specific host.
@ -2262,6 +2239,7 @@ async def get_host_shell_history(
limit: int = 100, limit: int = 100,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
): ):
""" """
Get shell history directly from the remote host via SSH. Get shell history directly from the remote host via SSH.
@ -2336,6 +2314,7 @@ async def get_host_unique_commands(
limit: int = 50, limit: int = 50,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
): ):
""" """
Get unique commands for a host (deduplicated). Get unique commands for a host (deduplicated).
@ -2387,6 +2366,7 @@ async def get_global_command_history(
offset: int = 0, offset: int = 0,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
): ):
""" """
Get command history globally (across all hosts). Get command history globally (across all hosts).
@ -2435,6 +2415,7 @@ async def clear_host_command_history(
host_id: str, host_id: str,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
): ):
""" """
Clear command history for a specific host. Clear command history for a specific host.
@ -2473,6 +2454,7 @@ async def purge_old_command_history(
days: int = 30, days: int = 30,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db), db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
): ):
""" """
Purge command history older than specified days. Purge command history older than specified days.

View File

@ -8,6 +8,8 @@ from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException, status from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException, status
from app.core.config import settings
from app.models.database import async_session_maker
from app.services import ws_manager from app.services import ws_manager
from app.crud.terminal_session import TerminalSessionRepository from app.crud.terminal_session import TerminalSessionRepository
from app.services.terminal_service import terminal_service from app.services.terminal_service import terminal_service
@ -45,18 +47,13 @@ async def terminal_websocket_proxy(
The session_id is used to look up the ttyd port from the database. The session_id is used to look up the ttyd port from the database.
Authentication is handled via session token in the query string. Authentication is handled via session token in the query string.
""" """
import socket
# Get token from query params # Get token from query params
token = websocket.query_params.get("token") token = websocket.query_params.get("token")
if not token: if not token:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Missing token") await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Missing token")
return return
# We need a DB session to look up the terminal session async with async_session_maker() as db_session:
# Since we're in a WebSocket endpoint, we need to create one manually
from app.core.database import SessionLocal
async with SessionLocal() as db_session:
session_repo = TerminalSessionRepository(db_session) session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id) session = await session_repo.get(session_id)

View File

@ -1,6 +1,9 @@
# Configuration du Homelab Automation Dashboard # Configuration du Homelab Automation Dashboard
# Copier ce fichier en .env et adapter les valeurs # Copier ce fichier en .env et adapter les valeurs
# utiliser le mode de debug
DEBUG_MODE=YES
# Clé API pour l'authentification (changer en production!) # Clé API pour l'authentification (changer en production!)
API_KEY=dev-key-12345 API_KEY=dev-key-12345

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,58 @@
# ✅ Vérification de santé
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `090ca848c78b4cf58621fea00238e0d3` |
| **Nom** | Vérification de santé |
| **Cible** | `ali2v.xeon.home` |
| **Statut** | completed |
| **Type** | Manuel |
| **Progression** | 100% |
| **Début** | 2025-12-21T22:11:58.985307+00:00 |
| **Fin** | 2025-12-21T22:12:10.412257+00:00 |
| **Durée** | 12.0s |
## Sortie
```
Using /mnt/c/dev/git/python/homelab-automation-api-v2/ansible/ansible.cfg as config file
PLAY [Health check on target host] *********************************************
TASK [Check if host is reachable (ping)] ***************************************
ok: [ali2v.xeon.home] => {"changed": false, "ping": "pong"}
TASK [Gather minimal facts] ****************************************************
ok: [ali2v.xeon.home]
TASK [Get system uptime] *******************************************************
ok: [ali2v.xeon.home] => {"changed": false, "cmd": ["uptime"], "delta": "0:00:00.002973", "end": "2025-12-21 17:12:06.706663", "msg": "", "rc": 0, "start": "2025-12-21 17:12:06.703690", "stderr": "", "stderr_lines": [], "stdout": " 17:12:06 up 1 day, 18:12, 1 user, load average: 0.10, 0.17, 0.13", "stdout_lines": [" 17:12:06 up 1 day, 18:12, 1 user, load average: 0.10, 0.17, 0.13"]}
TASK [Get disk usage] **********************************************************
ok: [ali2v.xeon.home] => {"changed": false, "cmd": "df -h / | tail -1 | awk '{print $5}'", "delta": "0:00:00.004021", "end": "2025-12-21 17:12:07.357901", "msg": "", "rc": 0, "start": "2025-12-21 17:12:07.353880", "stderr": "", "stderr_lines": [], "stdout": "22%", "stdout_lines": ["22%"]}
TASK [Get memory usage (Linux)] ************************************************
ok: [ali2v.xeon.home] => {"changed": false, "cmd": "if command -v free >/dev/null 2>&1; then\n free -m | grep Mem | awk '{printf \"%.1f%%\", $3/$2 * 100}'\nelse\n # Fallback for systems without free command\n cat /proc/meminfo | awk '/MemTotal/{total=$2} /MemAvailable/{avail=$2} END{printf \"%.1f%%\", (total-avail)/total*100}'\nfi\n", "delta": "0:00:00.004159", "end": "2025-12-21 17:12:08.122637", "msg": "", "rc": 0, "start": "2025-12-21 17:12:08.118478", "stderr": "", "stderr_lines": [], "stdout": "20.5%", "stdout_lines": ["20.5%"]}
TASK [Get CPU temperature (ARM/SBC)] *******************************************
ok: [ali2v.xeon.home] => {"changed": false, "cmd": "if [ -f /sys/class/thermal/thermal_zone0/temp ]; then\n temp=$(cat /sys/class/thermal/thermal_zone0/temp)\n # Use awk instead of bc for better compatibility\n echo \"${temp}\" | awk '{printf \"%.1f°C\", $1/1000}'\nelse\n echo \"N/A\"\nfi\n", "delta": "0:00:00.004811", "end": "2025-12-21 17:12:08.845155", "msg": "", "rc": 0, "start": "2025-12-21 17:12:08.840344", "stderr": "", "stderr_lines": [], "stdout": "45.0°C", "stdout_lines": ["45.0°C"]}
TASK [Get CPU load] ************************************************************
ok: [ali2v.xeon.home] => {"changed": false, "cmd": "if [ -f /proc/loadavg ]; then\n cat /proc/loadavg | awk '{print $1}'\nelse\n uptime | awk -F'load average:' '{print $2}' | awk -F',' '{print $1}' | tr -d ' '\nfi\n", "delta": "0:00:00.004006", "end": "2025-12-21 17:12:09.513591", "msg": "", "rc": 0, "start": "2025-12-21 17:12:09.509585", "stderr": "", "stderr_lines": [], "stdout": "0.17", "stdout_lines": ["0.17"]}
TASK [Display health status] ***************************************************
ok: [ali2v.xeon.home] => {
"msg": "═══════════════════════════════════════\nHost: ali2v.xeon.home\nStatus: OK\n═══════════════════════════════════════\nUptime: 17:12:06 up 1 day, 18:12, 1 user, load average: 0.10, 0.17, 0.13\nDisk Usage: 22%\nMemory Usage: 20.5%\nCPU Load: 0.17\nCPU Temp: 45.0°C\n═══════════════════════════════════════\n"
}
PLAY RECAP *********************************************************************
ali2v.xeon.home : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
```
---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: 2025-12-21T22:12:10.511917+00:00*

View File

@ -27,6 +27,7 @@ os.environ["API_KEY"] = "test-api-key-12345"
os.environ["JWT_SECRET_KEY"] = "test-jwt-secret-key-for-testing-only" os.environ["JWT_SECRET_KEY"] = "test-jwt-secret-key-for-testing-only"
os.environ["NTFY_ENABLED"] = "false" os.environ["NTFY_ENABLED"] = "false"
os.environ["ANSIBLE_DIR"] = "." os.environ["ANSIBLE_DIR"] = "."
os.environ["DEBUG_MODE"] = "YES"
from app.models.database import Base from app.models.database import Base
from app.core.dependencies import get_db, verify_api_key, get_current_user from app.core.dependencies import get_db, verify_api_key, get_current_user

View File

@ -0,0 +1,102 @@
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
from app.core.config import parse_env_bool, settings
from app.models.terminal_session import TerminalSession, SESSION_STATUS_ACTIVE
class TestParseEnvBool:
def test_parse_env_bool(self):
assert parse_env_bool("YES") is True
assert parse_env_bool("yes") is True
assert parse_env_bool("TRUE") is True
assert parse_env_bool("1") is True
assert parse_env_bool("NO") is False
assert parse_env_bool("no") is False
assert parse_env_bool("FALSE") is False
assert parse_env_bool("0") is False
assert parse_env_bool("invalid") is False
assert parse_env_bool(None) is False
assert parse_env_bool(None, default=True) is True
class TestDebugModeApi:
@pytest.mark.asyncio
async def test_config_endpoint_reflects_debug_mode(self, client, monkeypatch):
monkeypatch.setattr(settings, "debug_mode", False)
resp = await client.get("/api/config")
assert resp.status_code == 200
assert resp.json() == {"debug_mode": False}
@pytest.mark.asyncio
async def test_terminal_status_reports_disabled(self, client, auth_headers, monkeypatch):
monkeypatch.setattr(settings, "debug_mode", False)
with patch("app.services.terminal_service.terminal_service.check_ttyd_available", return_value=True):
resp = await client.get("/api/terminal/status", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["available"] is True
assert data["debug_mode"] is False
@pytest.mark.asyncio
async def test_terminal_connect_allowed_when_debug_disabled(self, client, db_session, monkeypatch):
monkeypatch.setattr(settings, "debug_mode", False)
now = datetime.now(timezone.utc)
sess = TerminalSession(
id="s" * 64,
host_id="host-1",
host_name="test-host",
host_ip="127.0.0.1",
user_id="user-1",
username="testuser",
token_hash="x" * 64,
ttyd_port=7680,
ttyd_pid=123,
mode="embedded",
status=SESSION_STATUS_ACTIVE,
created_at=now,
last_seen_at=now,
expires_at=now + timedelta(minutes=10),
)
db_session.add(sess)
await db_session.commit()
with patch("app.services.terminal_service.terminal_service.verify_token", return_value=True), \
patch("app.services.terminal_service.terminal_service.is_session_process_alive", new=AsyncMock(return_value=True)):
resp = await client.get(f"/api/terminal/connect/{sess.id}?token=dummy")
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_terminal_probe_forbidden_when_debug_disabled(self, client, db_session, monkeypatch):
monkeypatch.setattr(settings, "debug_mode", False)
now = datetime.now(timezone.utc)
sess = TerminalSession(
id="t" * 64,
host_id="host-1",
host_name="test-host",
host_ip="127.0.0.1",
user_id="user-1",
username="testuser",
token_hash="x" * 64,
ttyd_port=7681,
ttyd_pid=123,
mode="embedded",
status=SESSION_STATUS_ACTIVE,
created_at=now,
last_seen_at=now,
expires_at=now + timedelta(minutes=10),
)
db_session.add(sess)
await db_session.commit()
with patch("app.services.terminal_service.terminal_service.verify_token", return_value=True):
resp = await client.get(f"/api/terminal/sessions/{sess.id}/probe?token=dummy")
assert resp.status_code == 403
assert resp.json()["detail"] == "Debug mode disabled"

View File

@ -35,3 +35,36 @@ class TestTerminalConnectPage:
assert resp.status_code == 200 assert resp.status_code == 200
assert "<html" in resp.text.lower() assert "<html" in resp.text.lower()
assert "terminal" in resp.text.lower() assert "terminal" in resp.text.lower()
@pytest.mark.asyncio
async def test_popout_page_allowed_when_debug_disabled(self, client, db_session, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "debug_mode", False)
now = datetime.now(timezone.utc)
sess = TerminalSession(
id="p" * 64,
host_id="host-1",
host_name="test-host",
host_ip="127.0.0.1",
user_id="user-1",
username="testuser",
token_hash="x" * 64,
ttyd_port=7680,
ttyd_pid=123,
mode="popout",
status=SESSION_STATUS_ACTIVE,
created_at=now,
last_seen_at=now,
expires_at=now + timedelta(minutes=10),
)
db_session.add(sess)
await db_session.commit()
with patch("app.services.terminal_service.terminal_service.verify_token", return_value=True), \
patch("app.services.terminal_service.terminal_service.is_session_process_alive", new=AsyncMock(return_value=True)):
resp = await client.get(f"/api/terminal/popout/{sess.id}?token=dummy")
assert resp.status_code == 200
assert "<html" in resp.text.lower()

View File

@ -1,6 +1,9 @@
import pytest import pytest
from unittest.mock import patch from unittest.mock import patch
from app.services.terminal_service import TERMINAL_MAX_SESSIONS_PER_USER
class TestTerminalStatus: class TestTerminalStatus:
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
function loadMainJsAsGlobal() {
const mainPath = path.resolve(process.cwd(), 'app', 'main.js');
const source = fs.readFileSync(mainPath, 'utf8');
const wrapped = `${source}\n;globalThis.__DashboardManager = DashboardManager;`;
eval(wrapped);
return globalThis.__DashboardManager;
}
describe('DEBUG_MODE UI gating', () => {
beforeEach(() => {
document.body.innerHTML = `
<div class="desktop-nav-links">
<div class="relative group"></div>
</div>
<div id="hosts-list"></div>
`;
delete globalThis.__DashboardManager;
delete globalThis.DashboardManager;
});
it('shows DEBUG badge only when enabled', () => {
const DashboardManager = loadMainJsAsGlobal();
const dashboard = new DashboardManager();
dashboard.setDebugBadgeVisible(false);
expect(document.getElementById('debug-mode-badge')).toBeNull();
dashboard.setDebugBadgeVisible(true);
expect(document.getElementById('debug-mode-badge')?.textContent).toBe('DEBUG');
dashboard.setDebugBadgeVisible(false);
expect(document.getElementById('debug-mode-badge')).toBeNull();
});
it('mounts terminal buttons when debug is disabled', () => {
const DashboardManager = loadMainJsAsGlobal();
const dashboard = new DashboardManager();
dashboard.debugModeEnabled = false;
dashboard.hosts = [
{
id: 'h1',
name: 'host1',
ip: '127.0.0.1',
os: 'linux',
status: 'online',
bootstrap_ok: true,
groups: ['env_prod', 'role_web'],
last_seen: new Date().toISOString(),
},
];
dashboard.renderHosts();
expect(document.querySelector('[data-action="terminal"]')).not.toBeNull();
expect(document.querySelector('[data-action="terminal-popout"]')).not.toBeNull();
});
it('mounts terminal buttons when debug is enabled', () => {
const DashboardManager = loadMainJsAsGlobal();
const dashboard = new DashboardManager();
dashboard.debugModeEnabled = true;
dashboard.hosts = [
{
id: 'h1',
name: 'host1',
ip: '127.0.0.1',
os: 'linux',
status: 'online',
bootstrap_ok: true,
groups: ['env_prod', 'role_web'],
last_seen: new Date().toISOString(),
},
];
dashboard.renderHosts();
expect(document.querySelector('[data-action="terminal"]')).not.toBeNull();
expect(document.querySelector('[data-action="terminal-popout"]')).not.toBeNull();
});
});

View File

@ -68,6 +68,7 @@ export function createMockResponse(data, status = 200) {
export function setupFetchMock(responses = {}) { export function setupFetchMock(responses = {}) {
const defaultResponses = { const defaultResponses = {
'/api/auth/status': { setup_required: false, authenticated: true, user: { username: 'test' } }, '/api/auth/status': { setup_required: false, authenticated: true, user: { username: 'test' } },
'/api/config': { debug_mode: false },
'/api/hosts': [], '/api/hosts': [],
'/api/tasks': [], '/api/tasks': [],
'/api/schedules': [], '/api/schedules': [],