From 70c15c9b6f12d58d3cccebff280e45d0023265ea Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 21 Dec 2025 17:22:36 -0500 Subject: [PATCH] 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 --- app/core/config.py | 28 ++ app/core/dependencies.py | 9 + app/main.js | 135 +++++++- app/routes/__init__.py | 2 + app/routes/config.py | 12 + app/routes/terminal.py | 302 ++++++++---------- app/routes/websocket.py | 9 +- docker/.env.example | 3 + logs/tasks_logs/.metadata_cache.json | 2 +- ...on.home_Vérification_de_santé_completed.md | 58 ++++ tests/backend/conftest.py | 1 + tests/backend/test_debug_mode.py | 102 ++++++ tests/backend/test_terminal_connect_page.py | 33 ++ tests/backend/test_terminal_status.py | 3 + tests/frontend/debug_mode_ui.test.js | 87 +++++ tests/frontend/setup.js | 1 + 16 files changed, 606 insertions(+), 181 deletions(-) create mode 100644 app/routes/config.py create mode 100644 logs/tasks_logs/2025/12/21/task_221210_b3b719_ali2v.xeon.home_Vérification_de_santé_completed.md create mode 100644 tests/backend/test_debug_mode.py create mode 100644 tests/frontend/debug_mode_ui.test.js diff --git a/app/core/config.py b/app/core/config.py index 45b8f0e..86ba716 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -7,10 +7,29 @@ Toutes les variables d'environnement et paramètres sont centralisés ici. import os from pathlib import Path from typing import Optional + from pydantic import Field +from pydantic import field_validator 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): """Configuration de l'application Homelab Automation.""" @@ -113,6 +132,14 @@ class Settings(BaseSettings): ) 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: env_file = ".env" env_file_encoding = "utf-8" @@ -121,3 +148,4 @@ class Settings(BaseSettings): # Instance singleton de la configuration settings = Settings() +DEBUG_MODE_ENABLED: bool = settings.debug_mode diff --git a/app/core/dependencies.py b/app/core/dependencies.py index 14504fd..6387d9b 100644 --- a/app/core/dependencies.py +++ b/app/core/dependencies.py @@ -130,6 +130,15 @@ async def get_current_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 === async def require_admin( diff --git a/app/main.js b/app/main.js index eca40cb..b455096 100644 --- a/app/main.js +++ b/app/main.js @@ -84,6 +84,8 @@ class DashboardManager { // WebSocket this.ws = null; + + this.debugModeEnabled = false; // Terminal SSH this.terminalSession = null; @@ -114,7 +116,6 @@ class DashboardManager { this.tasksDisplayedCount = 20; this.tasksPerPage = 20; - this.init(); } async init() { @@ -136,6 +137,9 @@ class DashboardManager { // Hide login screen if visible this.hideLoginScreen(); + await this.loadAppConfig(); + this.setDebugBadgeVisible(this.isDebugEnabled()); + // Charger les données depuis l'API await this.loadAllData(); @@ -148,6 +152,72 @@ class DashboardManager { // Démarrer le polling des tâches en cours 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) { if (typeof navigateTo === 'function') { @@ -254,6 +324,8 @@ class DashboardManager { // Re-initialize dashboard this.hideLoginScreen(); + await this.loadAppConfig(); + this.setDebugBadgeVisible(this.isDebugEnabled()); await this.loadAllData(); this.connectWebSocket(); this.startRunningTasksPolling(); @@ -10290,16 +10362,22 @@ class DashboardManager { category = 'task'; } const title = type === 'success' ? 'Succès' : (type === 'warning' ? 'Avertissement' : (type === 'error' ? 'Erreur' : 'Info')); - this.apiCall('/api/alerts', { - method: 'POST', - body: JSON.stringify({ - category, - title, - level: type, - message: msgStr, - source: 'ui' - }) - }).catch(() => {}); + // IMPORTANT: do NOT use apiCall() here. + // apiCall() handles 401 by calling showNotification(), which would create an infinite loop + // when unauthenticated. + if (this.accessToken) { + fetch(`${this.apiBase}/api/alerts`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify({ + category, + title, + level: type, + message: msgStr, + source: 'ui' + }) + }).catch(() => {}); + } } catch (e) { // ignore } @@ -10465,9 +10543,26 @@ class DashboardManager { // 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 { 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); return data; } catch (e) { @@ -10546,13 +10641,21 @@ class DashboardManager { } async openTerminalPopout(hostId, hostName, hostIp) { - // Check if terminal feature is available - if (!this.terminalFeatureAvailable) { + // Prevent multiple popouts + if (this.terminalPopoutOpening) { + return; + } + this.terminalPopoutOpening = true; + try { const status = await this.checkTerminalFeatureStatus(); if (!status.available) { this.showNotification('Terminal SSH non disponible: ttyd n\'est pas installé', 'error'); 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'); @@ -11503,6 +11606,10 @@ class DashboardManager { document.addEventListener('DOMContentLoaded', () => { console.log('Creating 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'))); // Allow embedded terminal connect page to request closing the terminal drawer. diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 05e2061..b972891 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -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.lint import router as lint_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 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(lint_router, prefix="/playbooks", tags=["Lint"]) api_router.include_router(terminal_router, prefix="/terminal", tags=["Terminal"]) +api_router.include_router(config_router, tags=["Config"]) __all__ = [ "api_router", diff --git a/app/routes/config.py b/app/routes/config.py new file mode 100644 index 0000000..64de8df --- /dev/null +++ b/app/routes/config.py @@ -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), + } diff --git a/app/routes/terminal.py b/app/routes/terminal.py index 034c698..18153d9 100644 --- a/app/routes/terminal.py +++ b/app/routes/terminal.py @@ -24,7 +24,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSo from fastapi.responses import HTMLResponse, StreamingResponse 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.bootstrap_status import BootstrapStatusRepository 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) 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]: if 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()) return { "available": available, + "debug_mode": bool(settings.debug_mode), } @router.get("/sessions/{session_id}/probe") @@ -156,6 +208,7 @@ async def probe_terminal_session( session_id: str, token: str, request: Request, + debug_enabled: bool = Depends(require_debug_mode), db_session: AsyncSession = Depends(get_db), ): session_repo = TerminalSessionRepository(db_session) @@ -483,8 +536,8 @@ async def proxy_ttyd_websocket( @router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse) async def create_terminal_session( host_id: str, + session_request: TerminalSessionRequest, http_request: Request, - request: TerminalSessionRequest = TerminalSessionRequest(), current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): @@ -528,7 +581,7 @@ async def create_terminal_session( existing_session = await session_repo.find_reusable_session( user_id=user_id, host_id=host.id, - mode=request.mode, + mode=session_request.mode, 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}" 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_host = (http_request.url.netloc if http_request else "localhost") @@ -648,7 +701,7 @@ async def create_terminal_session( expires_at=expires_at, user_id=user_id, username=username, - mode=request.mode, + mode=session_request.mode, ) await db_session.commit() except Exception as e: @@ -673,7 +726,7 @@ async def create_terminal_session( 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 + 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_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}", expires_at=expires_at, ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60, - mode=request.mode, + mode=session_request.mode, host=TerminalSessionHost( id=host.id, name=host.name, @@ -697,51 +750,11 @@ async def create_terminal_session( 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, + current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): session_repo = TerminalSessionRepository(db_session) @@ -774,6 +787,7 @@ async def close_terminal_session( async def heartbeat_terminal_session( session_id: str, request: Request, + token: Optional[str] = None, db_session: AsyncSession = Depends(get_db), ): session_repo = TerminalSessionRepository(db_session) @@ -808,6 +822,7 @@ async def heartbeat_terminal_session( async def close_beacon_terminal_session( session_id: str, request: Request, + token: Optional[str] = None, db_session: AsyncSession = Depends(get_db), ): session_repo = TerminalSessionRepository(db_session) @@ -830,6 +845,7 @@ async def close_beacon_terminal_session( @router.post("/cleanup") async def cleanup_terminal_sessions( + debug_enabled: bool = Depends(require_debug_mode), current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): @@ -870,6 +886,7 @@ async def log_terminal_command( session_id: str, command_data: dict, request: Request, + current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): session_repo = TerminalSessionRepository(db_session) @@ -1424,21 +1441,24 @@ function executeHistoryCommand(index) { } }); """ + + debug_mode_enabled = bool(settings.debug_mode) + debug_panel_html = ( + """ +
+
+
Debug Terminal
+ +
+
boot: page_rendered\nsession_id: {session_id}\nttyd_port: {ttyd_port}\n
+
+ """.format(session_id=html.escape(session_id), ttyd_port=ttyd_port) + if debug_mode_enabled + else "" + ) - script_block = ( - "