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
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:
2025-12-21 17:22:36 -05:00
parent 421446bd13
commit 70c15c9b6f
16 changed files with 606 additions and 181 deletions
+28
View File
@@ -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
+9
View File
@@ -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(
+121 -14
View File
@@ -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.
+2
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.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",
+12
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),
}
+142 -160
View File
@@ -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 = (
"""
<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;\">
<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\nsession_id: {session_id}\nttyd_port: {ttyd_port}\n</div>
</div>
""".format(session_id=html.escape(session_id), ttyd_port=ttyd_port)
if debug_mode_enabled
else ""
)
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"
debug_js = (
"" if not debug_mode_enabled else
" let debugVisible = false;\n\n"
" function toggleDebug(force) {\n"
" debugVisible = typeof force === 'boolean' ? force : !debugVisible;\n"
@@ -1481,94 +1501,23 @@ function executeHistoryCommand(index) {
" 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"
)
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\n"
f"{debug_js}"
" async function sendHeartbeat() {\n"
" if (EMBED) return;\n"
" try {\n"
@@ -1650,6 +1599,42 @@ function executeHistoryCommand(index) {
" document.getElementById('pwaHint').classList.add('hidden');\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
+ "\n"
" startHeartbeat();\n"
@@ -2110,16 +2095,7 @@ function executeHistoryCommand(index) {
JavaScript est désactivé: le terminal web ne peut pas se charger.
</div>
</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;">
<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>
{debug_panel_html}
<iframe
id="terminalFrame"
src="about:blank"
@@ -2201,6 +2177,7 @@ async def get_host_command_history(
offset: int = 0,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get command history for a specific host.
@@ -2262,6 +2239,7 @@ async def get_host_shell_history(
limit: int = 100,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get shell history directly from the remote host via SSH.
@@ -2336,6 +2314,7 @@ async def get_host_unique_commands(
limit: int = 50,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get unique commands for a host (deduplicated).
@@ -2387,6 +2366,7 @@ async def get_global_command_history(
offset: int = 0,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get command history globally (across all hosts).
@@ -2435,6 +2415,7 @@ async def clear_host_command_history(
host_id: str,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Clear command history for a specific host.
@@ -2473,6 +2454,7 @@ async def purge_old_command_history(
days: int = 30,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Purge command history older than specified days.
+3 -6
View File
@@ -8,6 +8,8 @@ from typing import Optional
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.crud.terminal_session import TerminalSessionRepository
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.
Authentication is handled via session token in the query string.
"""
import socket
# Get token from query params
token = websocket.query_params.get("token")
if not token:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Missing token")
return
# We need a DB session to look up the terminal 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:
async with async_session_maker() as db_session:
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
+3
View File
@@ -1,6 +1,9 @@
# Configuration du Homelab Automation Dashboard
# 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!)
API_KEY=dev-key-12345
File diff suppressed because one or more lines are too long
@@ -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*
+1
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["NTFY_ENABLED"] = "false"
os.environ["ANSIBLE_DIR"] = "."
os.environ["DEBUG_MODE"] = "YES"
from app.models.database import Base
from app.core.dependencies import get_db, verify_api_key, get_current_user
+102
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"
@@ -35,3 +35,36 @@ class TestTerminalConnectPage:
assert resp.status_code == 200
assert "<html" 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()
+3
View File
@@ -1,6 +1,9 @@
import pytest
from unittest.mock import patch
from app.services.terminal_service import TERMINAL_MAX_SESSIONS_PER_USER
class TestTerminalStatus:
@pytest.mark.asyncio
+87
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();
});
});
+1
View File
@@ -68,6 +68,7 @@ export function createMockResponse(data, status = 200) {
export function setupFetchMock(responses = {}) {
const defaultResponses = {
'/api/auth/status': { setup_required: false, authenticated: true, user: { username: 'test' } },
'/api/config': { debug_mode: false },
'/api/hosts': [],
'/api/tasks': [],
'/api/schedules': [],