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
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
+58
@@ -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*
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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': [],
|
||||
|
||||
Reference in New Issue
Block a user