// Homelab Dashboard JavaScript - Intégration API class DashboardManager { constructor() { // Configuration API - JWT token stored in localStorage this.apiBase = window.location.origin; this.accessToken = localStorage.getItem("accessToken") || null; this.currentUser = null; this.setupRequired = false; // Données locales (seront remplies par l'API) this.hosts = []; this.tasks = []; this.logs = []; this.serverLogs = []; this.logsView = "server"; this.currentLogsSearch = ""; this.ansibleHosts = []; this.ansibleGroups = []; this.playbooks = []; // Alertes (centre de messages) this.alerts = []; this.alertsUnread = 0; // Logs de tâches depuis les fichiers markdown this.taskLogs = []; this.taskLogsStats = { total: 0, completed: 0, failed: 0, running: 0, pending: 0 }; this.taskLogsDates = { years: {} }; // Filtres actifs this.currentStatusFilter = "all"; this.currentDateFilter = { year: "", month: "", day: "" }; // Sélection de dates via le calendrier (liste de chaînes YYYY-MM-DD) this.selectedTaskDates = []; this.taskCalendarMonth = new Date(); // Filtres d'heure this.currentHourStart = ""; this.currentHourEnd = ""; // Filtre par type de source (scheduled, manual, adhoc) this.currentSourceTypeFilter = "all"; this.currentGroupFilter = "all"; this.currentBootstrapFilter = "all"; this.currentHostsSearch = ""; this.currentCategoryFilter = "all"; this.currentSubcategoryFilter = "all"; this.currentTargetFilter = "all"; this.expandedHostDiskDetails = new Set(); // Pagination côté serveur this.tasksTotalCount = 0; this.tasksHasMore = false; // Groupes pour la gestion des hôtes this.envGroups = []; this.roleGroups = []; // Catégories de playbooks this.playbookCategories = {}; // Filtres playbooks this.currentPlaybookCategoryFilter = "all"; this.currentPlaybookSearch = ""; // Historique des commandes ad-hoc this.adhocHistory = []; this.adhocCategories = []; // Exécutions ad-hoc (logs de tâches markdown) this.adhocWidgetLogs = []; this.adhocWidgetTotalCount = 0; this.adhocWidgetHasMore = false; // Métriques des hôtes (collectées par les builtin playbooks) this.hostMetrics = {}; // Map host_id -> HostMetricsSummary this.builtinPlaybooks = []; this.metricsLoading = false; // Schedules (Planificateur) this.schedules = []; this.schedulesStats = { total: 0, active: 0, paused: 0, failures_24h: 0 }; this.schedulesUpcoming = []; this.currentScheduleFilter = "all"; this.scheduleSearchQuery = ""; this.scheduleCalendarMonth = new Date(); this.editingScheduleId = null; this.scheduleModalStep = 1; // WebSocket this.ws = null; this.wsReconnectDelay = 1000; this.debugModeEnabled = false; // Terminal SSH this.terminalSession = null; this.terminalDrawerOpen = false; this.terminalFeatureAvailable = false; this.terminalCommandHistory = []; this.terminalHistorySearchQuery = ""; this.terminalHistoryLoading = false; this.terminalHeartbeatInterval = null; this.terminalHeartbeatIntervalMs = 15000; // 15 seconds this.terminalOpeningPromise = null; // Prevent double-click // Terminal History Enhanced State this.terminalHistoryPanelOpen = false; this.terminalHistoryPanelPinned = false; // true = panel stays open (docked mode) this.terminalHistoryPinnedOnly = false; // true = show only pinned commands this.terminalHistorySelectedIndex = -1; this.terminalHistoryTimeFilter = "all"; // all, today, week, month this.terminalHistoryStatusFilter = "all"; // all, success, error this._terminalHistoryKeyHandler = null; // Configuration: collecte métriques this.metricsCollectionInterval = "off"; // Polling des tâches en cours this.runningTasksPollingInterval = null; this.pollingIntervalMs = 2000; // Polling toutes les 2 secondes // Pagination des tâches this.tasksDisplayedCount = 20; this.tasksPerPage = 20; } renderContainerCustomizationColorPicker(initialColor) { const defaultColor = "#7c3aed"; const color = initialColor && initialColor.startsWith("#") && initialColor.length >= 4 ? initialColor : ""; const pickerValue = color || defaultColor; const palette = ["#7c3aed", "#3b82f6", "#06b6d4", "#10b981", "#84cc16", "#f59e0b", "#f97316", "#ef4444", "#ec4899", "#a855f7"]; return `
${palette .map( (c) => ` `, ) .join("")}
`; } renderContainerCustomizationBgColorPicker(initialColor) { const defaultColor = ""; const color = initialColor && initialColor.startsWith("#") && initialColor.length >= 4 ? initialColor : ""; const pickerValue = color || "#111827"; const palette = ["", "#111827", "#1f2937", "#0b1220", "#2a2a2a", "#0f172a", "#1e1b4b", "#3b0764", "#450a0a", "#052e16"]; return `
${palette .map((c) => { const label = c || "Aucune"; const style = c ? `background:${c}` : "background:transparent"; return ``; }) .join("")}
`; } setContainerCustomIconColor(color) { const text = document.getElementById("container-custom-icon-color-text"); const picker = document.getElementById("container-custom-icon-color-picker"); if (text) text.value = color; if (picker) picker.value = color; this.refreshContainerCustomIconPreview(); } clearContainerCustomIconColor() { const text = document.getElementById("container-custom-icon-color-text"); const picker = document.getElementById("container-custom-icon-color-picker"); if (text) text.value = ""; if (picker) picker.value = "#7c3aed"; this.refreshContainerCustomIconPreview(); } syncContainerCustomIconColorFromPicker() { const text = document.getElementById("container-custom-icon-color-text"); const picker = document.getElementById("container-custom-icon-color-picker"); if (!text || !picker) return; text.value = picker.value; this.refreshContainerCustomIconPreview(); } syncContainerCustomIconColorFromText() { const text = document.getElementById("container-custom-icon-color-text"); const picker = document.getElementById("container-custom-icon-color-picker"); if (!text || !picker) return; const v = String(text.value || "").trim(); const isHex = /^#[0-9a-fA-F]{6}$/.test(v) || /^#[0-9a-fA-F]{3}$/.test(v); if (isHex) picker.value = v; this.refreshContainerCustomIconPreview(); } setContainerCustomBgColor(color) { const text = document.getElementById("container-custom-bg-color-text"); const picker = document.getElementById("container-custom-bg-color-picker"); if (text) text.value = color; if (picker) picker.value = color || "#111827"; this.refreshContainerCustomIconPreview(); } clearContainerCustomBgColor() { const text = document.getElementById("container-custom-bg-color-text"); const picker = document.getElementById("container-custom-bg-color-picker"); if (text) text.value = ""; if (picker) picker.value = "#111827"; this.refreshContainerCustomIconPreview(); } syncContainerCustomBgColorFromPicker() { const text = document.getElementById("container-custom-bg-color-text"); const picker = document.getElementById("container-custom-bg-color-picker"); if (!text || !picker) return; text.value = picker.value; this.refreshContainerCustomIconPreview(); } syncContainerCustomBgColorFromText() { const text = document.getElementById("container-custom-bg-color-text"); const picker = document.getElementById("container-custom-bg-color-picker"); if (!text || !picker) return; const v = String(text.value || "").trim(); const isHex = /^#[0-9a-fA-F]{6}$/.test(v) || /^#[0-9a-fA-F]{3}$/.test(v); if (isHex) picker.value = v; this.refreshContainerCustomIconPreview(); } renderContainerCustomizationIconPicker(initialIconKey) { const iconKey = initialIconKey || ""; const iconColor = document.getElementById("container-custom-icon-color-text")?.value || "#7c3aed"; const bgColor = document.getElementById("container-custom-bg-color-text")?.value || ""; const bgStyle = bgColor ? `background:${this.escapeHtml(bgColor)};` : ""; return `
${iconKey ? `` : ''}
${iconKey ? `` : ""}
`; } openContainerIconPicker() { const currentIconKey = document.getElementById("container-custom-icon-key")?.value || ""; if (window.iconPicker) { window.iconPicker.show((selectedIcon) => { this.setContainerCustomIcon(selectedIcon); }, currentIconKey); } } setContainerCustomIcon(iconKey) { const input = document.getElementById("container-custom-icon-key"); if (input) input.value = iconKey; this.refreshContainerCustomIconPreview(); } clearContainerCustomIcon() { const input = document.getElementById("container-custom-icon-key"); if (input) input.value = ""; this.refreshContainerCustomIconPreview(); } refreshContainerCustomIconPreview() { const preview = document.getElementById("container-custom-icon-preview"); const iconKey = document.getElementById("container-custom-icon-key")?.value || ""; const iconColor = document.getElementById("container-custom-icon-color-text")?.value?.trim() || "#7c3aed"; const bgColor = document.getElementById("container-custom-bg-color-text")?.value?.trim() || ""; if (!preview) return; preview.setAttribute("style", bgColor ? `background:${this.escapeHtml(bgColor)};` : ""); preview.innerHTML = iconKey ? `` : ''; } async showEditContainerModal(hostId, containerId, containerName = "") { const cm = window.containerCustomizationsManager; if (!cm) { this.showNotification("Customisation indisponible", "error"); return; } try { await cm.ensureInit(); const existing = cm.get(hostId, containerId); const iconKey = existing?.icon_key || ""; const iconColor = existing?.icon_color || ""; const bgColor = existing?.bg_color || ""; const title = containerName ? `Modifier le container: ${this.escapeHtml(containerName)}` : "Modifier le container"; this.showModal( title, `
${this.renderContainerCustomizationColorPicker(this.escapeHtml(iconColor))}
${this.renderContainerCustomizationBgColorPicker(this.escapeHtml(bgColor))}
${this.renderContainerCustomizationIconPicker(this.escapeHtml(iconKey))}
`, ); } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } } async saveContainerCustomization(event, hostId, containerId) { event.preventDefault(); const cm = window.containerCustomizationsManager; if (!cm) return; const iconKey = document.getElementById("container-custom-icon-key")?.value?.trim() || null; const iconColor = document.getElementById("container-custom-icon-color-text")?.value?.trim() || null; const bgColor = document.getElementById("container-custom-bg-color-text")?.value?.trim() || null; try { await cm.upsert(hostId, containerId, { icon_key: iconKey, icon_color: iconColor, bg_color: bgColor }); this.closeModal(); this.showNotification("Container mis à jour", "success"); if (window.containersPage?.render) window.containersPage.render(); if (window.dockerSection?.currentHostId) window.dockerSection.loadContainers(window.dockerSection.currentHostId); this.renderFavoriteContainersWidget(); } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } } async resetContainerCustomization(hostId, containerId) { const cm = window.containerCustomizationsManager; if (!cm) return; try { await cm.remove(hostId, containerId); this.closeModal(); this.showNotification("Customisation supprimée", "success"); if (window.containersPage?.render) window.containersPage.render(); if (window.dockerSection?.currentHostId) window.dockerSection.loadContainers(window.dockerSection.currentHostId); this.renderFavoriteContainersWidget(); } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } } renderFavoriteGroupColorPicker(initialColor) { const defaultColor = "#7c3aed"; const color = initialColor && initialColor.startsWith("#") && initialColor.length >= 4 ? initialColor : ""; const pickerValue = color || defaultColor; const palette = ["#7c3aed", "#3b82f6", "#06b6d4", "#10b981", "#84cc16", "#f59e0b", "#f97316", "#ef4444", "#ec4899", "#a855f7"]; return `
${palette .map( (c) => ` `, ) .join("")}
`; } setFavGroupColor(color) { const text = document.getElementById("fav-group-color-text"); const picker = document.getElementById("fav-group-color-picker"); if (text) text.value = color; if (picker) picker.value = color; } clearFavGroupColor() { const text = document.getElementById("fav-group-color-text"); const picker = document.getElementById("fav-group-color-picker"); if (text) text.value = ""; if (picker) picker.value = "#7c3aed"; } syncFavGroupColorFromPicker() { const text = document.getElementById("fav-group-color-text"); const picker = document.getElementById("fav-group-color-picker"); if (!text || !picker) return; text.value = picker.value; } syncFavGroupColorFromText() { const text = document.getElementById("fav-group-color-text"); const picker = document.getElementById("fav-group-color-picker"); if (!text || !picker) return; const v = String(text.value || "").trim(); const isHex = /^#[0-9a-fA-F]{6}$/.test(v) || /^#[0-9a-fA-F]{3}$/.test(v); if (isHex) picker.value = v; } renderFavoriteGroupIconPicker(initialIconKey) { const iconKey = initialIconKey || ""; const color = document.getElementById("fav-group-color-text")?.value || "#7c3aed"; return `
${iconKey ? `` : ''}
${iconKey ? `` : ""}
`; } openIconPicker() { const currentIconKey = document.getElementById("fav-group-icon-key")?.value || ""; if (window.iconPicker) { window.iconPicker.show((selectedIcon) => { this.setFavGroupIcon(selectedIcon); }, currentIconKey); } } setFavGroupIcon(iconKey) { const input = document.getElementById("fav-group-icon-key"); const preview = document.getElementById("fav-group-icon-preview"); const color = document.getElementById("fav-group-color-text")?.value || "#7c3aed"; if (input) input.value = iconKey; if (preview) { preview.innerHTML = ``; } } clearFavGroupIcon() { const input = document.getElementById("fav-group-icon-key"); const preview = document.getElementById("fav-group-icon-preview"); if (input) input.value = ""; if (preview) { preview.innerHTML = ''; } } async init() { this.setupEventListeners(); this.setupScrollAnimations(); this.startAnimations(); this.loadThemePreference(); this.setupTerminalCleanupHandlers(); // Check authentication status first const authOk = await this.checkAuthStatus(); if (!authOk) { // Show login screen this.showLoginScreen(); return; } // Hide login screen if visible this.hideLoginScreen(); if (window.favoritesManager) { try { await window.favoritesManager.ensureInit(); window.favoritesManager.onChange(() => { this.renderFavoriteContainersWidget(); }); } catch (e) { } } if (window.containerCustomizationsManager) { try { await window.containerCustomizationsManager.ensureInit(); window.containerCustomizationsManager.onChange(() => { this.renderFavoriteContainersWidget(); if (window.containersPage?.render) window.containersPage.render(); if (window.dockerSection?.currentHostId) window.dockerSection.loadContainers(window.dockerSection.currentHostId); }); } catch (e) { } } await this.loadAppConfig(); this.setDebugBadgeVisible(this.isDebugEnabled()); // Charger les données depuis l'API await this.loadAllData(); this.renderFavoriteContainersWidget(); // Connecter WebSocket pour les mises à jour temps réel this.connectWebSocket(); // Rafraîchir périodiquement les métriques setInterval(() => this.loadMetrics(), 30000); if (window.favoritesManager) { setInterval(() => { window.favoritesManager.load().catch(() => null); }, 30000); } // Initialize Mission Control Dashboard Widgets if script is loaded if (typeof renderAllWidgets === "function") { renderAllWidgets(); } // 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]+)/gi, (m, p1, p2) => `${p1}${mask(p2)}`); out = out.replace(/\b(token|access_token|api_key|apikey|secret|password)\s*[:=]\s*([^\s'"\r\n]+)/gi, (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") { navigateTo(pageName); return; } // Fallback minimal si navigateTo n'est pas disponible document.querySelectorAll(".page-section").forEach((page) => { page.classList.remove("active"); }); const target = document.getElementById(`page-${pageName}`); if (target) target.classList.add("active"); } // ===== AUTHENTICATION ===== async checkAuthStatus() { try { const response = await fetch(`${this.apiBase}/api/auth/status`, { headers: this.getAuthHeaders(), }); if (!response.ok) { return false; } const data = await response.json(); this.setupRequired = data.setup_required; if (data.setup_required) { this.showSetupScreen(); return false; } if (data.authenticated && data.user) { this.currentUser = data.user; this.updateUserDisplay(); return true; } return false; } catch (error) { console.error("Auth status check failed:", error); return false; } } handleTaskLogDeleted(payload) { const logId = payload && payload.id; if (!logId) return; const current = Array.isArray(this.taskLogs) ? this.taskLogs : []; const next = current.filter((l) => String(l.id) !== String(logId)); if (next.length === current.length) return; this.taskLogs = next; this.renderTasks(); } handleTaskLogCreated(log) { if (!log || !log.id) return; const current = Array.isArray(this.taskLogs) ? this.taskLogs : []; const exists = current.some((l) => String(l.id) === String(log.id)); if (exists) return; this.taskLogs = [log, ...current]; this.renderTasks(); } getAuthHeaders() { const headers = { "Content-Type": "application/json", }; if (this.accessToken) { headers["Authorization"] = `Bearer ${this.accessToken}`; } // No fallback - require JWT authentication return headers; } async login(username, password) { try { const response = await fetch(`${this.apiBase}/api/auth/login/json`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || "Échec de connexion"); } const data = await response.json(); this.accessToken = data.access_token; localStorage.setItem("accessToken", data.access_token); // Get user info await this.checkAuthStatus(); // Re-initialize dashboard this.hideLoginScreen(); await this.loadAppConfig(); this.setDebugBadgeVisible(this.isDebugEnabled()); await this.loadAllData(); this.connectWebSocket(); this.initNetworkListeners(); this.startRunningTasksPolling(); this.showNotification("Connexion réussie", "success"); return true; } catch (error) { console.error("Login failed:", error); this.showNotification(error.message, "error"); return false; } } async setupAdmin(username, password, email = null, displayName = null) { const response = await fetch(`${this.apiBase}/api/auth/setup`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, email: email || null, display_name: displayName || null, }), }); if (!response.ok) { let errorMessage = "Échec de configuration"; try { const errorData = await response.json(); if (errorData.detail) { if (Array.isArray(errorData.detail)) { errorMessage = errorData.detail.map((err) => err.msg).join(", "); } else { errorMessage = errorData.detail; } } } catch (e) { console.error("Could not parse error response:", e); } throw new Error(errorMessage); } // Auto-login after setup return await this.login(username, password); } logout() { this.accessToken = null; this.currentUser = null; localStorage.removeItem("accessToken"); // Stop polling if (this.runningTasksPollingInterval) { clearInterval(this.runningTasksPollingInterval); } // Close WebSocket if (this.ws) { this.ws.close(); } this.showLoginScreen(); this.showNotification("Déconnexion réussie", "success"); } showLoginScreen() { const loginScreen = document.getElementById("login-screen"); const mainContent = document.getElementById("main-content"); if (loginScreen) { loginScreen.classList.remove("hidden"); if (this.setupRequired) { document.getElementById("login-form-container").classList.add("hidden"); document.getElementById("setup-form-container").classList.remove("hidden"); } else { document.getElementById("login-form-container").classList.remove("hidden"); document.getElementById("setup-form-container").classList.add("hidden"); } } if (mainContent) { mainContent.classList.add("hidden"); } } showSetupScreen() { this.setupRequired = true; this.showLoginScreen(); } hideLoginScreen() { const loginScreen = document.getElementById("login-screen"); const mainContent = document.getElementById("main-content"); if (loginScreen) { loginScreen.classList.add("hidden"); } if (mainContent) { mainContent.classList.remove("hidden"); } } updateUserDisplay() { const userNameEl = document.getElementById("current-user-name"); const userMenuNameEl = document.getElementById("user-menu-name"); const userRoleEl = document.getElementById("current-user-role"); if (this.currentUser) { const displayName = this.currentUser.display_name || this.currentUser.username; if (userNameEl) { userNameEl.textContent = displayName; } if (userMenuNameEl) { userMenuNameEl.textContent = displayName; } if (userRoleEl) { const roleLabels = { admin: "Administrateur", operator: "Opérateur", viewer: "Lecteur", }; userRoleEl.textContent = roleLabels[this.currentUser.role] || this.currentUser.role; } } } // ===== API CALLS ===== async apiCall(endpoint, options = {}) { const url = `${this.apiBase}${endpoint}`; const defaultOptions = { headers: this.getAuthHeaders(), }; try { const response = await fetch(url, { ...defaultOptions, ...options }); if (!response.ok) { // Handle 401 Unauthorized - redirect to login if (response.status === 401) { this.showNotification("Session expirée, reconnexion requise", "error"); this.logout(); const err = new Error("Session expirée"); err.status = 401; throw err; } let errorDetail = null; try { const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { errorDetail = await response.json(); } else { const text = await response.text(); errorDetail = text ? { detail: text } : null; } } catch (_) { errorDetail = null; } const serverMessage = errorDetail && (errorDetail.detail || errorDetail.message || errorDetail.error) ? errorDetail.detail || errorDetail.message || errorDetail.error : response.statusText; const err = new Error(`HTTP ${response.status}: ${serverMessage || "Erreur inconnue"}`); err.status = response.status; err.detail = errorDetail; throw err; } return await response.json(); } catch (error) { console.error(`API Error (${endpoint}):`, error); throw error; } } async loadAllData() { try { // Charger en parallèle const [hostsData, tasksData, logsData, metricsData, inventoryData, playbooksData, taskLogsData, taskStatsData, taskDatesData, adhocHistoryData, adhocCategoriesData, adhocTaskLogsData, schedulesData, schedulesStatsData, hostMetricsData, builtinPlaybooksData, serverLogsData, alertsUnreadData] = await Promise.all([ this.apiCall("/api/hosts").catch(() => []), this.apiCall("/api/tasks").catch(() => []), this.apiCall("/api/logs").catch(() => []), this.apiCall("/api/monitoring").catch(() => ({})), this.apiCall("/api/ansible/inventory").catch(() => ({ hosts: [], groups: [] })), this.apiCall("/api/ansible/playbooks").catch(() => ({ playbooks: [] })), this.apiCall("/api/tasks/logs").catch(() => ({ logs: [], count: 0 })), this.apiCall("/api/tasks/logs/stats").catch(() => ({ total: 0, completed: 0, failed: 0, running: 0, pending: 0 })), this.apiCall("/api/tasks/logs/dates").catch(() => ({ years: {} })), this.apiCall("/api/adhoc/history").catch(() => ({ commands: [], count: 0 })), this.apiCall("/api/adhoc/categories").catch(() => ({ categories: [] })), this.apiCall("/api/tasks/logs?source_type=adhoc&limit=500&offset=0").catch(() => ({ logs: [], total_count: 0, has_more: false })), this.apiCall("/api/schedules").catch(() => ({ schedules: [], count: 0 })), this.apiCall("/api/schedules/stats").catch(() => ({ stats: {}, upcoming: [] })), this.apiCall("/api/monitoring/all-hosts").catch(() => ({})), this.apiCall("/api/builtin-playbooks").catch(() => []), this.apiCall("/api/server/logs?limit=500&offset=0").catch(() => ({ logs: [] })), this.apiCall("/api/alerts/unread-count").catch(() => ({ unread: 0 })), ]); this.hosts = hostsData; this.tasks = tasksData; this.logs = logsData; this.serverLogs = serverLogsData.logs || []; this.ansibleHosts = inventoryData.hosts || []; this.ansibleGroups = inventoryData.groups || []; this.playbooks = playbooksData.playbooks || []; this.playbookCategories = playbooksData.categories || {}; this.alertsUnread = alertsUnreadData.unread || 0; this.updateAlertsBadge(); // Logs de tâches markdown this.taskLogs = taskLogsData.logs || []; this.taskLogsStats = taskStatsData; this.taskLogsDates = taskDatesData; // Historique ad-hoc this.adhocHistory = adhocHistoryData.commands || []; this.adhocCategories = adhocCategoriesData.categories || []; // Exécutions ad-hoc (logs) this.adhocWidgetLogs = adhocTaskLogsData.logs || []; this.adhocWidgetTotalCount = Number(adhocTaskLogsData.total_count || this.adhocWidgetLogs.length || 0); this.adhocWidgetHasMore = Boolean(adhocTaskLogsData.has_more); // Schedules (Planificateur) this.schedules = schedulesData.schedules || []; this.schedulesStats = schedulesStatsData.stats || { total: 0, active: 0, paused: 0, failures_24h: 0 }; this.schedulesUpcoming = schedulesStatsData.upcoming || []; // Host metrics (builtin playbooks data) this.hostMetrics = hostMetricsData || {}; this.builtinPlaybooks = builtinPlaybooksData || []; console.log("Data loaded:", { taskLogs: this.taskLogs.length, taskLogsStats: this.taskLogsStats, adhocHistory: this.adhocHistory.length, adhocCategories: this.adhocCategories.length, schedules: this.schedules.length, }); // Charger les résultats de lint depuis l'API await this.loadPlaybookLintResults(); // Mettre à jour l'affichage this.renderHosts(); this.renderTasks(); this.renderLogs(); this.renderAlerts(); this.renderPlaybooks(); this.renderSchedules(); this.renderAdhocWidget(); this.updateMetricsDisplay(metricsData); this.updateDateFilters(); this.updateTaskCounts(); } catch (error) { console.error("Erreur chargement données:", error); this.showNotification("Erreur de connexion à l'API", "error"); } } async loadMetrics() { try { const metrics = await this.apiCall("/api/monitoring"); this.updateMetricsDisplay(metrics); } catch (error) { console.error("Erreur chargement métriques:", error); } } updateMetricsDisplay(metrics) { if (!metrics) return; const elements = { "online-hosts": metrics.online_hosts, "total-tasks": metrics.total_tasks, "success-rate": `${metrics.success_rate}%`, uptime: `${metrics.uptime}%`, }; Object.entries(elements).forEach(([id, value]) => { const el = document.getElementById(id); if (el && value !== undefined) { el.textContent = value; } }); } // ===== WEBSOCKET ===== initNetworkListeners() { window.addEventListener("online", () => { console.log("Réseau rétabli — reconnexion WebSocket..."); this.hideOfflineBanner(); this.wsReconnectDelay = 1000; this.connectWebSocket(); }); window.addEventListener("offline", () => { console.log("Réseau perdu — mode hors ligne"); this.showOfflineBanner(); }); } showOfflineBanner() { if (document.getElementById("offline-banner")) return; const banner = document.createElement("div"); banner.id = "offline-banner"; banner.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:9999;background:linear-gradient(135deg,#ef4444,#dc2626);color:#fff;text-align:center;padding:8px 16px;font-size:14px;font-weight:500;font-family:Inter,sans-serif;box-shadow:0 2px 8px rgba(0,0,0,0.3);"; banner.innerHTML = 'Hors ligne — les données seront rafraîchies au retour du réseau'; document.body.prepend(banner); } hideOfflineBanner() { const banner = document.getElementById("offline-banner"); if (banner) banner.remove(); } connectWebSocket() { // Don't attempt to connect while offline if (typeof navigator !== "undefined" && !navigator.onLine) { console.log("Hors ligne — connexion WebSocket reportée"); return; } const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${wsProtocol}//${window.location.host}/ws`; try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log("WebSocket connecté"); this.wsReconnectDelay = 1000; // Reset backoff on success }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); this.handleWebSocketMessage(data); }; this.ws.onclose = () => { const delay = this.wsReconnectDelay || 1000; console.log(`WebSocket déconnecté, reconnexion dans ${delay / 1000}s...`); setTimeout(() => this.connectWebSocket(), delay); // Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s max this.wsReconnectDelay = Math.min(delay * 2, 30000); }; this.ws.onerror = (error) => { console.error("WebSocket erreur:", error); }; } catch (error) { console.error("Erreur WebSocket:", error); } } handleWebSocketMessage(data) { // Broadcast into the widget's Live Events feed if initialized if (typeof window.renderWidget === "function") { if (!this.liveEvents) this.liveEvents = []; this.liveEvents.unshift({ type: data.type || "info", message: data.data?.message || data.type, host: data.data?.host || "—", status: data.data?.status || "info", ts: Date.now(), }); if (this.liveEvents.length > 50) this.liveEvents.length = 50; // Debounce rendering clearTimeout(this._liveRenderTimeout); this._liveRenderTimeout = setTimeout(() => { try { window.renderWidget("live-feed"); } catch (e) { } }, 100); } switch (data.type) { case "task_created": // Nouvelle tâche créée - mettre à jour immédiatement this.handleTaskCreated(data.data); break; case "task_completed": case "task_failed": // Tâche terminée - mettre à jour et rafraîchir les logs this.handleTaskCompleted(data.data); break; case "task_cancelled": // Tâche annulée - mettre à jour l'UI this.handleTaskCancelled(data.data); break; case "task_progress": // Mise à jour de progression - mettre à jour l'UI dynamiquement this.handleTaskProgress(data.data); break; case "task_log_deleted": this.handleTaskLogDeleted(data.data); break; case "task_log_created": this.handleTaskLogCreated(data.data); break; case "host_created": case "host_deleted": this.loadAllData(); break; case "new_log": case "logs_cleared": this.loadLogs(); break; case "alert_created": this.handleAlertCreated(data.data); break; case "alerts_unread_count": if (data.data && typeof data.data.unread === "number") { this.alertsUnread = data.data.unread; this.updateAlertsBadge(); } break; case "ansible_execution": this.showNotification(data.data.success ? "Playbook exécuté avec succès" : "Échec du playbook", data.data.success ? "success" : "error"); break; case "bootstrap_success": this.showNotification(`Bootstrap réussi pour ${data.data.host}`, "success"); this.loadAllData(); break; case "bootstrap_status_updated": this.loadAllData(); break; case "schedule_created": this.handleScheduleCreated(data.data); break; case "schedule_updated": this.handleScheduleUpdated(data.data); break; case "schedule_deleted": this.handleScheduleDeleted(data.data); break; case "schedule_run_started": this.handleScheduleRunStarted(data.data); break; case "schedule_run_finished": this.handleScheduleRunFinished(data.data); break; case "metrics_collection_complete": this.showNotification(`Collecte des métriques terminée: ${data.data?.success || 0}/${data.data?.total || 0} réussi(es)`, data.data && data.data.failed && data.data.failed > 0 ? "warning" : "success"); this.loadHostMetrics().then(() => { this.renderHosts(); }); this.loadLogs().then(() => { this.renderLogs(); }); break; } } // ===== HANDLERS WEBSOCKET SCHEDULES ===== handleScheduleCreated(schedule) { this.schedules.unshift(schedule); this.renderSchedules(); this.showNotification(`Schedule "${schedule.name}" créé`, "success"); } handleScheduleUpdated(schedule) { const index = this.schedules.findIndex((s) => s.id === schedule.id); if (index !== -1) { this.schedules[index] = schedule; } this.renderSchedules(); } handleScheduleDeleted(data) { this.schedules = this.schedules.filter((s) => s.id !== data.id); this.renderSchedules(); this.showNotification(`Schedule "${data.name}" supprimé`, "warning"); } handleScheduleRunStarted(data) { this.showNotification(`Schedule "${data.schedule_name}" démarré`, "info"); // Mettre à jour le statut du schedule const schedule = this.schedules.find((s) => s.id === data.schedule_id); if (schedule) { schedule.last_status = "running"; this.renderSchedules(); } } handleScheduleRunFinished(data) { const statusMsg = data.success ? "terminé avec succès" : "échoué"; const notifType = data.success ? "success" : "error"; this.showNotification(`Schedule "${data.schedule_name}" ${statusMsg}`, notifType); // Mettre à jour le schedule const schedule = this.schedules.find((s) => s.id === data.schedule_id); if (schedule && data.run) { schedule.last_status = data.run.status; schedule.last_run_at = data.run.finished_at; } this.renderSchedules(); // Rafraîchir les stats this.refreshSchedulesStats(); } // ===== POLLING DES TÂCHES EN COURS ===== startRunningTasksPolling() { // Arrêter le polling existant si présent this.stopRunningTasksPolling(); // Démarrer le polling this.runningTasksPollingInterval = setInterval(() => { this.pollRunningTasks(); }, this.pollingIntervalMs); // Exécuter immédiatement une première fois this.pollRunningTasks(); console.log("Polling des tâches en cours démarré"); } stopRunningTasksPolling() { if (this.runningTasksPollingInterval) { clearInterval(this.runningTasksPollingInterval); this.runningTasksPollingInterval = null; console.log("Polling des tâches en cours arrêté"); } } async pollRunningTasks() { try { const result = await this.apiCall("/api/tasks/running"); const runningTasks = result.tasks || []; // Vérifier si des tâches ont changé de statut const previousRunningIds = this.tasks.filter((t) => t.status === "running" || t.status === "pending").map((t) => t.id); const currentRunningIds = runningTasks.map((t) => t.id); // Détecter les tâches terminées const completedTaskIds = previousRunningIds.filter((id) => !currentRunningIds.includes(id)); if (completedTaskIds.length > 0) { // Des tâches ont été terminées - rafraîchir les logs console.log("Tâches terminées détectées:", completedTaskIds); await this.refreshTaskLogs(); } // Mettre à jour les tâches en cours this.updateRunningTasks(runningTasks); } catch (error) { console.error("Erreur polling tâches:", error); } } updateRunningTasks(runningTasks) { // Mettre à jour la liste des tâches en mémoire const nonRunningTasks = this.tasks.filter((t) => t.status !== "running" && t.status !== "pending"); this.tasks = [...runningTasks, ...nonRunningTasks]; // Mettre à jour l'affichage dynamiquement this.updateRunningTasksUI(runningTasks); this.updateTaskCounts(); } updateRunningTasksUI(runningTasks) { const container = document.getElementById("tasks-list"); if (!container) return; // Trouver ou créer la section des tâches en cours let runningSection = container.querySelector(".running-tasks-section"); if (runningTasks.length === 0) { // Supprimer la section si plus de tâches en cours if (runningSection) { runningSection.remove(); } return; } // Créer la section si elle n'existe pas if (!runningSection) { runningSection = document.createElement("div"); runningSection.className = "running-tasks-section mb-4"; runningSection.innerHTML = '

En cours

'; // Insérer au début du container (après le header) const header = container.querySelector(".flex.flex-col"); if (header && header.nextSibling) { container.insertBefore(runningSection, header.nextSibling); } else { container.prepend(runningSection); } } // Mettre à jour le contenu des tâches en cours const tasksContainer = runningSection.querySelector(".running-tasks-list") || document.createElement("div"); tasksContainer.className = "running-tasks-list space-y-2"; tasksContainer.innerHTML = runningTasks.map((task) => this.createRunningTaskHTML(task)).join(""); if (!runningSection.querySelector(".running-tasks-list")) { runningSection.appendChild(tasksContainer); } // Mettre à jour le badge "en cours" dans le header const runningBadge = container.querySelector(".running-badge"); if (runningBadge) { runningBadge.textContent = `${runningTasks.length} en cours`; } } createRunningTaskHTML(task) { const startTime = task.start_time ? new Date(task.start_time).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "--:--"; const duration = task.duration || this.calculateDuration(task.start_time); const progress = task.progress || 0; return `

${this.escapeHtml(task.name)}

ACTIVE_INST
${this.escapeHtml(task.host)}
START: ${startTime} • DUR: ${duration}
EXECUTION PROGRESS ${progress}%
`; } calculateDuration(startTime) { if (!startTime) return "--"; const start = new Date(startTime); const now = new Date(); const diffMs = now - start; const diffSec = Math.floor(diffMs / 1000); if (diffSec < 60) return `${diffSec}s`; const diffMin = Math.floor(diffSec / 60); const remainingSec = diffSec % 60; if (diffMin < 60) return `${diffMin}m ${remainingSec}s`; const diffHour = Math.floor(diffMin / 60); const remainingMin = diffMin % 60; return `${diffHour}h ${remainingMin}m`; } // ===== HANDLERS WEBSOCKET POUR LES TÂCHES ===== handleTaskCreated(taskData) { console.log("Nouvelle tâche créée:", taskData); // Ajouter la tâche à la liste const existingIndex = this.tasks.findIndex((t) => t.id === taskData.id); if (existingIndex === -1) { this.tasks.push(taskData); } else { this.tasks[existingIndex] = taskData; } // Mettre à jour l'UI immédiatement this.updateRunningTasksUI(this.tasks.filter((t) => t.status === "running" || t.status === "pending")); this.updateTaskCounts(); // Notification this.showNotification(`Tâche "${taskData.name}" démarrée`, "info"); // Refresh Mission Control widgets if (typeof renderAllWidgets === "function") { renderAllWidgets(); } } handleTaskProgress(progressData) { console.log("Progression tâche:", progressData); const taskId = progressData && (progressData.task_id || progressData.id); if (!taskId) return; // Mettre à jour la tâche dans la liste const task = this.tasks.find((t) => String(t.id) === String(taskId)); if (task) { task.progress = progressData.progress; // Mettre à jour l'UI de cette tâche spécifique const taskCard = document.querySelector(`.task-card-${taskId}`); if (taskCard) { const progressBar = taskCard.querySelector(".bg-blue-500"); const progressText = taskCard.querySelector(".text-gray-500.mt-1"); if (progressBar) { progressBar.style.width = `${progressData.progress}%`; } if (progressText) { progressText.textContent = `${progressData.progress}% complété`; } } } } handleTaskCompleted(taskData) { console.log("Tâche terminée:", taskData); const taskId = taskData && (taskData.task_id || taskData.id); if (!taskId) return; // Retirer la tâche de la liste des tâches en cours this.tasks = this.tasks.filter((t) => String(t.id) !== String(taskId)); // Mettre à jour l'UI this.updateRunningTasksUI(this.tasks.filter((t) => t.status === "running" || t.status === "pending")); // Rafraîchir les logs de tâches pour voir la tâche terminée this.refreshTaskLogs(); // Rafraîchir aussi les hosts/métriques : un health-check met à jour status/last_seen côté backend // (sinon l'indicateur de santé peut rester sur l'ancien état) this.loadAllData().catch((e) => { console.error("Erreur rafraîchissement données après fin de tâche:", e); }); // Notification const status = taskData.status || "completed"; const isSuccess = status === "completed"; this.showNotification(`Tâche terminée: ${isSuccess ? "Succès" : "Échec"}`, isSuccess ? "success" : "error"); // Refresh Mission Control widgets if (typeof renderAllWidgets === "function") { renderAllWidgets(); } } handleTaskCancelled(taskData) { console.log("Tâche annulée:", taskData); // Retirer la tâche de la liste des tâches en cours this.tasks = this.tasks.filter((t) => String(t.id) !== String(taskData.id)); // Mettre à jour l'UI this.updateRunningTasksUI(this.tasks.filter((t) => t.status === "running" || t.status === "pending")); // Rafraîchir les logs de tâches this.refreshTaskLogs(); // Notification this.showNotification("Tâche annulée", "warning"); } async loadLogs() { try { const logsData = await this.apiCall("/api/logs"); this.logs = logsData; this.renderLogs(); } catch (error) { console.error("Erreur chargement logs:", error); } } async loadServerLogs() { try { const data = await this.apiCall("/api/server/logs?limit=500&offset=0"); this.serverLogs = data.logs || []; this.renderLogs(); } catch (error) { console.error("Erreur chargement logs serveur:", error); } } setLogsView(view) { this.logsView = view === "db" ? "db" : "server"; if (this.logsView === "server") { this.loadServerLogs(); } else { this.loadLogs(); } } async refreshSystemLogs() { try { if (this.logsView === "server") { await this.loadServerLogs(); } else { await this.loadLogs(); } this.scrollLogsToTop(); this.showNotification("Logs mis à jour", "success"); } catch (error) { console.error("Erreur rafraîchissement logs:", error); this.showNotification("Erreur lors de la mise à jour des logs", "error"); } } scrollLogsToTop() { const container = document.getElementById("logs-container"); if (!container) return; container.scrollTop = 0; } setupEventListeners() { // Theme toggle (desktop + mobile) const onToggleTheme = () => { this.toggleTheme(); }; document.getElementById("theme-toggle")?.addEventListener("click", onToggleTheme); document.getElementById("theme-toggle-mobile")?.addEventListener("click", onToggleTheme); document.getElementById("mobile-theme-toggle")?.addEventListener("click", onToggleTheme); // Expose for inline handlers (e.g. mobile nav button) window.toggleTheme = onToggleTheme; const logsSearchInput = document.getElementById("logs-search"); if (logsSearchInput) { logsSearchInput.addEventListener("input", (e) => { const val = e.target && typeof e.target.value === "string" ? e.target.value : ""; this.currentLogsSearch = val; this.renderLogs(); }); } const favoritesSearchInput = document.getElementById("dashboard-favorites-search"); if (favoritesSearchInput) { favoritesSearchInput.addEventListener("input", () => { this.renderFavoriteContainersWidget(); }); } // Metrics collection configuration (Configuration page) const configPage = document.getElementById("page-configuration"); if (configPage) { const obsConfig = new MutationObserver(() => { if (configPage.classList.contains("active")) { this.loadMetricsCollectionSchedule(); } }); obsConfig.observe(configPage, { attributes: true, attributeFilter: ["class"] }); } const metricsSaveBtn = document.getElementById("metrics-collection-save"); if (metricsSaveBtn) { metricsSaveBtn.addEventListener("click", () => { this.saveMetricsCollectionSchedule(); }); } // Auto-refresh alerts when Alerts page becomes active const alertsPage = document.getElementById("page-alerts"); if (alertsPage) { const obs = new MutationObserver(() => { if (alertsPage.classList.contains("active")) { this.refreshAlerts(); this.refreshAlertsCount(); } }); obs.observe(alertsPage, { attributes: true, attributeFilter: ["class"] }); } // Initialiser le calendrier de filtrage des tâches this.setupTaskDateCalendar(); // Event delegation for terminal buttons (avoids inline onclick issues) document.addEventListener("click", (e) => { const btn = e.target.closest('[data-action="terminal"], [data-action="terminal-popout"]'); if (!btn || btn.disabled) return; e.stopPropagation(); const action = btn.dataset.action; const hostId = btn.dataset.hostId; const hostName = btn.dataset.hostName; const hostIp = btn.dataset.hostIp; if (action === "terminal") { this.openTerminal(hostId, hostName, hostIp); } else if (action === "terminal-popout") { this.openTerminalPopout(hostId, hostName, hostIp); } }); // Navigation est gérée par le script de navigation des pages dans index.html } async loadMetricsCollectionSchedule() { const selectEl = document.getElementById("metrics-collection-interval"); const currentEl = document.getElementById("metrics-collection-current"); if (!selectEl) return; try { const data = await this.apiCall("/api/monitoring/collection-schedule"); const interval = data?.interval || "off"; this.metricsCollectionInterval = interval; selectEl.value = interval; if (currentEl) currentEl.textContent = `Actuel: ${interval}`; } catch (error) { console.error("Erreur chargement cédule métriques:", error); if (currentEl) currentEl.textContent = "Erreur de chargement"; } } async saveMetricsCollectionSchedule() { const selectEl = document.getElementById("metrics-collection-interval"); const currentEl = document.getElementById("metrics-collection-current"); if (!selectEl) return; const interval = selectEl.value || "off"; try { await this.apiCall("/api/monitoring/collection-schedule", { method: "POST", body: JSON.stringify({ interval }), }); this.metricsCollectionInterval = interval; if (currentEl) currentEl.textContent = `Actuel: ${interval}`; this.showNotification("Période de collecte sauvegardée (" + interval + ")", "success"); } catch (error) { console.error("Erreur sauvegarde cédule métriques:", error); this.showNotification("Erreur lors de la sauvegarde", "error"); } } // ===== CALENDRIER DE FILTRAGE DES TÂCHES ===== setupTaskDateCalendar() { const wrapper = document.getElementById("task-date-filter-wrapper"); const button = document.getElementById("task-date-filter-button"); const calendar = document.getElementById("task-date-calendar"); const prevBtn = document.getElementById("task-cal-prev-month"); const nextBtn = document.getElementById("task-cal-next-month"); const clearBtn = document.getElementById("task-cal-clear"); const applyBtn = document.getElementById("task-cal-apply"); if (!wrapper || !button || !calendar) { return; // Section tâches pas présente } // État initial this.taskCalendarMonth = new Date(); this.selectedTaskDates = this.selectedTaskDates || []; const toggleCalendar = (open) => { const shouldOpen = typeof open === "boolean" ? open : calendar.classList.contains("hidden"); if (shouldOpen) { calendar.classList.remove("hidden"); this.renderTaskCalendar(); } else { calendar.classList.add("hidden"); } }; button.addEventListener("click", (event) => { event.stopPropagation(); toggleCalendar(); }); document.addEventListener("click", (event) => { if (!wrapper.contains(event.target)) { calendar.classList.add("hidden"); } }); prevBtn?.addEventListener("click", (event) => { event.stopPropagation(); this.changeTaskCalendarMonth(-1); }); nextBtn?.addEventListener("click", (event) => { event.stopPropagation(); this.changeTaskCalendarMonth(1); }); clearBtn?.addEventListener("click", (event) => { event.stopPropagation(); this.selectedTaskDates = []; this.updateDateFilters(); this.renderTaskCalendar(); }); applyBtn?.addEventListener("click", (event) => { event.stopPropagation(); this.applyDateFilter(); calendar.classList.add("hidden"); }); // Premier rendu this.updateDateFilters(); this.renderTaskCalendar(); } changeTaskCalendarMonth(delta) { const base = this.taskCalendarMonth instanceof Date ? this.taskCalendarMonth : new Date(); const d = new Date(base); d.setMonth(d.getMonth() + delta); this.taskCalendarMonth = d; this.renderTaskCalendar(); } renderTaskCalendar() { const grid = document.getElementById("task-cal-grid"); const monthLabel = document.getElementById("task-cal-current-month"); if (!grid || !monthLabel) return; const base = this.taskCalendarMonth instanceof Date ? this.taskCalendarMonth : new Date(); const year = base.getFullYear(); const month = base.getMonth(); const monthNames = ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"]; monthLabel.textContent = `${monthNames[month]} ${year}`; grid.innerHTML = ""; const firstDayOfMonth = new Date(year, month, 1); const firstDayOfWeek = firstDayOfMonth.getDay(); // 0 (dimanche) - 6 (samedi) const daysInMonth = new Date(year, month + 1, 0).getDate(); const prevMonthLastDay = new Date(year, month, 0).getDate(); const totalCells = 42; // 6 lignes * 7 colonnes for (let i = 0; i < totalCells; i++) { const cell = document.createElement("div"); cell.className = "flex justify-center items-center py-0.5"; let date; if (i < firstDayOfWeek) { // Jours du mois précédent const day = prevMonthLastDay - (firstDayOfWeek - 1 - i); date = new Date(year, month - 1, day); } else if (i < firstDayOfWeek + daysInMonth) { // Jours du mois courant const day = i - firstDayOfWeek + 1; date = new Date(year, month, day); } else { // Jours du mois suivant const day = i - (firstDayOfWeek + daysInMonth) + 1; date = new Date(year, month + 1, day); } const btn = document.createElement("button"); btn.type = "button"; const key = this.getDateKey(date); const isCurrentMonth = date.getMonth() === month; const isSelected = this.selectedTaskDates.includes(key); const today = new Date(); today.setHours(0, 0, 0, 0); const isToday = date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate(); let classes = "w-9 h-9 flex items-center justify-center rounded-full text-xs transition-colors duration-150 "; if (!isCurrentMonth) { classes += "text-gray-600"; btn.disabled = true; } else { btn.dataset.date = key; if (isSelected) { classes += "bg-purple-600 text-white hover:bg-purple-500 cursor-pointer"; } else if (isToday) { classes += "border border-purple-400 text-purple-200 hover:bg-gray-800 cursor-pointer"; } else { classes += "text-gray-200 hover:bg-gray-800 cursor-pointer"; } btn.addEventListener("click", (event) => { event.stopPropagation(); this.toggleTaskDateSelection(key); }); } btn.className = classes; btn.textContent = String(date.getDate()); cell.appendChild(btn); grid.appendChild(cell); } } toggleTaskDateSelection(key) { const index = this.selectedTaskDates.indexOf(key); if (index > -1) { this.selectedTaskDates.splice(index, 1); } else { this.selectedTaskDates.push(key); } // Garder les dates triées this.selectedTaskDates.sort(); this.updateDateFilters(); this.renderTaskCalendar(); } getDateKey(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } parseDateKey(key) { const [y, m, d] = key.split("-").map((v) => parseInt(v, 10)); return new Date(y, (m || 1) - 1, d || 1); } toggleTheme() { const body = document.body; const isLight = body.classList.toggle("light-theme"); const themeIcons = document.querySelectorAll("#theme-toggle i, #theme-toggle-mobile i, #mobile-theme-icon"); themeIcons.forEach((icon) => { icon.className = isLight ? "fas fa-sun text-yellow-400" : "fas fa-moon text-gray-300"; }); const mobileLabel = document.getElementById("mobile-theme-label"); if (mobileLabel) { mobileLabel.textContent = isLight ? "Thème clair" : "Thème sombre"; } localStorage.setItem("theme", isLight ? "light" : "dark"); } loadThemePreference() { const savedTheme = localStorage.getItem("theme"); if (savedTheme === "light") { document.body.classList.add("light-theme"); const themeIcons = document.querySelectorAll("#theme-toggle i, #theme-toggle-mobile i, #mobile-theme-icon"); themeIcons.forEach((icon) => { icon.className = "fas fa-sun text-yellow-400"; }); const mobileLabel = document.getElementById("mobile-theme-label"); if (mobileLabel) { mobileLabel.textContent = "Thème clair"; } } } renderHosts() { const container = document.getElementById("hosts-list"); const hostsPageContainer = document.getElementById("hosts-page-list"); if (!container && !hostsPageContainer) return; // Compter les hôtes par statut bootstrap const readyCount = this.hosts.filter((h) => h.bootstrap_ok).length; const notConfiguredCount = this.hosts.filter((h) => !h.bootstrap_ok).length; // Mise à jour des compteurs dans le DOM (si présents sur la page Hosts) const totalSpan = document.getElementById("hosts-stat-total"); if (totalSpan) totalSpan.innerHTML = `TOTAL NODES ${this.hosts.length}`; const onlineSpan = document.getElementById("hosts-stat-online"); if (onlineSpan) onlineSpan.innerHTML = `ACTIVE NODES ${this.hosts.filter((h) => h.status === "online").length}`; const groupSelect = document.getElementById("host-group-filter-select"); if (groupSelect) { const currentVal = groupSelect.value; const groupOptions = this.ansibleGroups.map((g) => ``).join(""); groupSelect.innerHTML = `${groupOptions}`; groupSelect.value = currentVal; } // Header condensé pour le widget dashboard uniquement const widgetHeaderHtml = `
Fleet Readiness
${readyCount} Configured ${notConfiguredCount} Pending
`; if (container) container.innerHTML = widgetHeaderHtml; if (hostsPageContainer) hostsPageContainer.innerHTML = ""; // Filtrage let filteredHosts = this.hosts; if (this.currentGroupFilter && this.currentGroupFilter !== "all") { filteredHosts = filteredHosts.filter((h) => h.groups && h.groups.includes(this.currentGroupFilter)); } if (this.currentBootstrapFilter && this.currentBootstrapFilter !== "all") { filteredHosts = filteredHosts.filter((h) => (this.currentBootstrapFilter === "ready" ? h.bootstrap_ok : !h.bootstrap_ok)); } const q = (this.currentHostsSearch || "").trim().toLowerCase(); if (q) { filteredHosts = filteredHosts.filter((h) => { const name = (h.name || "").toLowerCase(); const ip = (h.ip || "").toLowerCase(); const os = (h.os || "").toLowerCase(); const groups = (h.groups || []).join(" ").toLowerCase(); return name.includes(q) || ip.includes(q) || os.includes(q) || groups.includes(q); }); } if (filteredHosts.length === 0) { const emptyHtml = `

Aucun hôte trouvé

Ajustez vos filtres ou ajoutez un nouvel hôte.

`; if (hostsPageContainer) hostsPageContainer.innerHTML = emptyHtml; if (container) container.innerHTML += emptyHtml; return; } filteredHosts.forEach((host) => { const isOnline = host.status === "online"; const lastSeen = host.last_seen ? new Date(host.last_seen).toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }) : "Jamais"; const bootstrapOk = host.bootstrap_ok || false; const bootstrapIndicator = bootstrapOk ? ` READY ` : ` PENDING `; const commQuality = this.getHostCommunicationQuality(host); const commIndicator = `
${[1, 2, 3, 4, 5] .map( (i) => `
`, ) .join("")}
`; const hostGroups = host.groups || []; const envGroup = hostGroups.find((g) => g.startsWith("env_")); const roleGroups = hostGroups.filter((g) => g.startsWith("role_")); const envBadge = envGroup ? `${envGroup.replace("env_", "")}` : ""; const roleBadges = roleGroups.map((g) => `${g.replace("role_", "")}`).join(""); const hostMetrics = this.hostMetrics[host.id] || null; const cardHtml = `

${host.name}

${host.ip} | ${host.os}

${this.renderHostMetricsMinimal(hostMetrics)}
${envBadge} ${roleBadges}
${bootstrapIndicator}
Signal ${commIndicator}
${lastSeen}
`; if (hostsPageContainer) { const el = document.createElement("div"); el.innerHTML = cardHtml; hostsPageContainer.appendChild(el.firstElementChild); } if (container) { const item = document.createElement("div"); item.className = "mb-3 last:mb-0"; item.innerHTML = cardHtml; container.appendChild(item); } }); // Setup event delegation for host action buttons this.setupHostActionButtons(); } setupHostActionButtons() { // Use event delegation on the hosts containers const containerIds = ["hosts-page-container", "dashboard-hosts", "hosts-page-list"]; containerIds.forEach(id => { const container = document.getElementById(id); if (!container || container._actionButtonsSetup) return; container._actionButtonsSetup = true; container.addEventListener("click", (e) => { const btn = e.target.closest(".host-action-btn, .host-terminal-btn, .host-terminal-popout-btn, .host-manage-btn, .host-edit-btn"); if (!btn) return; e.stopPropagation(); if (btn.classList.contains("host-terminal-btn")) { const hostId = btn.dataset.terminal; const hostName = btn.dataset.name; const hostIp = btn.dataset.ip; if (hostId && !btn.disabled) { this.openTerminal(hostId, hostName, hostIp); } } else if (btn.classList.contains("host-terminal-popout-btn")) { const hostId = btn.dataset.terminalPopout; const hostName = btn.dataset.name; const hostIp = btn.dataset.ip; if (hostId && !btn.disabled) { this.openTerminalPopout(hostId, hostName, hostIp); } } else if (btn.classList.contains("host-manage-btn")) { const hostName = btn.dataset.manageHost; if (hostName) this.manageHost(hostName); } else if (btn.classList.contains("host-edit-btn")) { const hostName = btn.dataset.editHost; if (hostName) this.showEditHostModal(hostName); } else if (btn.dataset.action) { const action = btn.dataset.action; const hostName = btn.dataset.host; const hostIp = btn.dataset.ip; if (action === "bootstrap") { this.showBootstrapModal(hostName, hostIp); } else { this.executeHostAction(action, hostName); } } }); }); } renderHostMetricsMinimal(metrics) { if (!metrics || metrics.collection_status === "unknown") { return `
Metrics pending
`; } const cpu = Math.round(metrics.cpu_usage || 0); const ram = Math.round(metrics.memory_usage_percent || 0); return `
CPU ${cpu}%
RAM ${ram}%
`; } filterHostsByBootstrap(status) { this.currentBootstrapFilter = status; this.renderHosts(); } filterHostsBySearch(query) { this.currentHostsSearch = query || ""; this.renderHosts(); } toggleHostDiskDetails(hostId) { if (!hostId) return; if (this.expandedHostDiskDetails.has(hostId)) { this.expandedHostDiskDetails.delete(hostId); } else { this.expandedHostDiskDetails.add(hostId); } this.renderHosts(); } // Render metrics section for a host card renderHostMetricsSection(metrics, hostId) { if (!metrics || metrics.collection_status === "unknown") { return `
METRICS UNAVAILABLE COLLECT METRICS TO SYNC
`; } // Format the last collected time const lastCollected = metrics.last_collected ? new Date(metrics.last_collected).toLocaleString("en-US", { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }) : "NEVER"; // CPU gauge const cpuPercent = metrics.cpu_load_1m ? Math.min(100, metrics.cpu_load_1m * 25) : 0; const cpuColor = cpuPercent > 80 ? "bg-red-500" : cpuPercent > 50 ? "bg-yellow-500" : "bg-green-500"; const cpuText = metrics.cpu_load_1m ? `${metrics.cpu_load_1m.toFixed(2)}` : "N/A"; const cpuCores = metrics.cpu_cores || null; const cpuThreads = metrics.cpu_threads || null; const cpuMaxMhz = metrics.cpu_max_mhz || null; const cpuFreqText = cpuMaxMhz ? `${(cpuMaxMhz / 1000).toFixed(1)}GHz` : ""; const cpuDetailParts = []; if (cpuCores && cpuThreads) cpuDetailParts.push(`${cpuCores}c/${cpuThreads}t`); else if (cpuThreads) cpuDetailParts.push(`${cpuThreads}t`); if (cpuFreqText) cpuDetailParts.push(cpuFreqText); const cpuDetailLine = cpuDetailParts.length ? cpuDetailParts.join(" • ") : ""; const cpuTitle = metrics.cpu_model ? this.escapeHtml(metrics.cpu_model) : ""; // Memory gauge const memPercent = metrics.memory_usage_percent || 0; const memColor = memPercent > 80 ? "bg-red-500" : memPercent > 50 ? "bg-yellow-500" : "bg-green-500"; const memText = memPercent ? `${memPercent.toFixed(0)}%` : "N/A"; const memDetail = metrics.memory_total_mb ? `${Math.round((metrics.memory_used_mb / 1024) * 10) / 10}/${Math.round((metrics.memory_total_mb / 1024) * 10) / 10} GB` : ""; // Disk gauge const diskPercent = metrics.disk_root_usage_percent || 0; const diskColor = diskPercent > 90 ? "bg-red-500" : diskPercent > 70 ? "bg-yellow-500" : "bg-green-500"; const diskText = diskPercent ? `${diskPercent.toFixed(0)}%` : "N/A"; const diskDetail = metrics.disk_root_total_gb ? `${metrics.disk_root_used_gb?.toFixed(0) || 0}/${metrics.disk_root_total_gb?.toFixed(0) || 0} GB` : ""; const effectiveHostId = hostId || metrics.host_id || ""; const diskDevices = Array.isArray(metrics.disk_devices) ? metrics.disk_devices : []; const disks = diskDevices.filter((d) => d && d.type === "disk"); const diskCount = disks.length || 0; const diskCountText = diskCount ? `${diskCount} disque${diskCount > 1 ? "s" : ""}` : ""; const diskInfo = Array.isArray(metrics.disk_info) ? metrics.disk_info : []; const showDiskDetails = diskCount > 0 || diskInfo.length > 0; const diskExpanded = effectiveHostId && this.expandedHostDiskDetails.has(effectiveHostId); const formatBytes = (bytes) => { if (bytes === null || bytes === undefined || bytes === "") return ""; const n = Number(bytes); if (!Number.isFinite(n) || n <= 0) return ""; const gb = n / (1024 * 1024 * 1024); if (gb >= 100) return `${gb.toFixed(0)} GB`; if (gb >= 10) return `${gb.toFixed(1)} GB`; return `${gb.toFixed(2)} GB`; }; const formatGb = (gb) => { if (gb === null || gb === undefined || gb === "") return ""; const n = Number(gb); if (!Number.isFinite(n) || n < 0) return ""; if (n >= 100) return `${n.toFixed(0)} GB`; if (n >= 10) return `${n.toFixed(1)} GB`; return `${n.toFixed(2)} GB`; }; const clampPct = (pct) => { const n = Number(pct); if (!Number.isFinite(n)) return null; return Math.max(0, Math.min(100, n)); }; const getPctColor = (pct) => { if (pct === null || pct === undefined) return "bg-gray-600"; return pct >= 90 ? "bg-red-500" : pct >= 75 ? "bg-yellow-500" : "bg-green-500"; }; const renderDiskDevices = () => { if (!diskExpanded) return ""; if (!disks.length && !diskInfo.length) return ""; const bytesToGbNumber = (bytes) => { const n = Number(bytes); if (!Number.isFinite(n) || n <= 0) return null; return n / (1024 * 1024 * 1024); }; const normalizeDevice = (s) => { if (!s) return ""; return String(s) .replace(/^\/dev\//, "") .trim(); }; const parseHumanSizeToGb = (val) => { if (val === null || val === undefined) return null; const s = String(val).trim(); if (!s) return null; const m = s.match(/^([0-9]+(?:\.[0-9]+)?)([KMGTP]?)B?$/i); if (!m) return null; const n = Number(m[1]); if (!Number.isFinite(n)) return null; const unit = (m[2] || "").toUpperCase(); const mult = unit === "T" ? 1024 : unit === "G" ? 1 : unit === "M" ? 1 / 1024 : unit === "K" ? 1 / 1024 / 1024 : 1; return n * mult; }; const lvmInfo = metrics.lvm_info && typeof metrics.lvm_info === "object" ? metrics.lvm_info : null; const zfsInfo = metrics.zfs_info && typeof metrics.zfs_info === "object" ? metrics.zfs_info : null; function findMountMetricsForPartition(pnameRaw, pmountRaw) { const pname = normalizeDevice(pnameRaw); const pmount = String(pmountRaw || "").trim(); // 1) Match by mountpoint if (pmount) { const byMount = diskInfo.find((m) => String(m.mount || m.mount_point || "").trim() === pmount); if (byMount) return byMount; } // 2) Match by device name (/dev/sda2 vs sda2) if (pname) { const byDev = diskInfo.find((m) => normalizeDevice(m.device || "") === pname); if (byDev) return byDev; } return null; } const lvmPvDevices = new Set((Array.isArray(lvmInfo?.pvs) ? lvmInfo.pvs : []).map((pv) => normalizeDevice(pv?.pv_name || pv?.pv || pv?.device || "")).filter(Boolean)); const isUsedFstype = (fstype) => { const fs = String(fstype || "").toLowerCase(); if (!fs) return false; return fs.includes("lvm") || fs.includes("zfs") || fs.includes("crypt") || fs.includes("mdraid") || fs.includes("raid"); }; const isPartitionUsed = (p) => { if (!p) return false; const mount = String(p.mountpoint || "").trim(); if (mount) return true; const name = normalizeDevice(p.name || ""); if (name && lvmPvDevices.has(name)) return true; const fstype = p.fstype || ""; if (isUsedFstype(fstype)) return true; const mountMetrics = findMountMetricsForPartition(p.name || "", p.mountpoint || ""); if (mountMetrics) return true; return false; }; const isDiskUsed = (d) => { if (!d) return false; if (String(d.mountpoint || "").trim()) return true; if (isUsedFstype(d.fstype)) return true; const children = Array.isArray(d.children) ? d.children : []; return children.some(isPartitionUsed); }; const usedDisks = disks.filter(isDiskUsed); const renderMountUsage = (mountMetrics) => { if (!mountMetrics) return ""; // Allow fallback objects to pass these fields too const pct = clampPct(mountMetrics.usage_percent); let totalGb = mountMetrics.total_gb !== undefined && mountMetrics.total_gb !== null ? Number(mountMetrics.total_gb) : null; let freeGb = mountMetrics.free_gb !== undefined && mountMetrics.free_gb !== null ? Number(mountMetrics.free_gb) : null; let usedGb = mountMetrics.used_gb !== undefined && mountMetrics.used_gb !== null ? Number(mountMetrics.used_gb) : null; if (totalGb !== null && usedGb === null && freeGb !== null) { usedGb = Math.max(0, totalGb - freeGb); } // If free/used missing but percent is available, derive them if (totalGb !== null && (freeGb === null || usedGb === null) && pct !== null) { usedGb = usedGb === null ? totalGb * (pct / 100) : usedGb; freeGb = freeGb === null ? Math.max(0, totalGb - usedGb) : freeGb; } const left = []; if (usedGb !== null) left.push(`${formatGb(usedGb)} used`); if (freeGb !== null) left.push(`${formatGb(freeGb)} free`); const right = totalGb !== null ? `${formatGb(totalGb)} total` : ""; const pctText = pct !== null ? `${pct.toFixed(0)}%` : ""; const color = getPctColor(pct); return `
${[...left, pctText].filter(Boolean).join(" • ")} ${right}
`; }; const diskCards = usedDisks .map((d) => { const model = d.model ? this.escapeHtml(String(d.model)) : ""; const name = d.name ? this.escapeHtml(String(d.name)) : ""; const size = formatBytes(d.size); const children = Array.isArray(d.children) ? d.children : []; const parts = children .filter((c) => c && (c.type === "part" || c.type === "lvm" || c.type === "crypt")) .filter(isPartitionUsed) .map((p) => { const pname = p.name ? this.escapeHtml(String(p.name)) : ""; const pfstype = p.fstype ? this.escapeHtml(String(p.fstype)) : ""; const pmountRaw = p.mountpoint ? String(p.mountpoint) : ""; const pmount = pmountRaw ? this.escapeHtml(pmountRaw) : ""; const psize = formatBytes(p.size); const mountMetricsRaw = findMountMetricsForPartition(p.name || "", pmountRaw); let mountMetrics = mountMetricsRaw; const isStackMember = !pmountRaw && isUsedFstype(p.fstype || ""); if (!mountMetrics && !isStackMember) { const totalGbFromSize = bytesToGbNumber(p.size); if (totalGbFromSize !== null) { mountMetrics = { usage_percent: null, total_gb: totalGbFromSize, free_gb: null, used_gb: null, }; } } // If total is missing in disk_info, try to fill from partition size if (mountMetrics && (mountMetrics.total_gb === undefined || mountMetrics.total_gb === null) && p.size) { const totalGbFromSize = bytesToGbNumber(p.size); if (totalGbFromSize !== null) { mountMetrics = { ...mountMetrics, total_gb: totalGbFromSize }; } } const titleLeft = [pname, pfstype].filter(Boolean).join(" • "); const titleRight = [pmount ? `↳ ${pmount}` : "", psize].filter(Boolean).join(" • "); const pctText = mountMetrics && mountMetrics.usage_percent !== undefined && mountMetrics.usage_percent !== null ? `${Number(mountMetrics.usage_percent).toFixed(0)}%` : isStackMember ? "N/A" : ""; return `
${titleLeft || " "}
${titleRight}
${pctText}
${renderMountUsage(mountMetrics)}
`; }) .join(""); return `
${name || "disk"}${model ? ` • ${model}` : ""}
${size || ""}
${children.length ? `${children.length} part.` : ""}
${parts ? `
${parts}
` : `
Aucune partition utilisée détectée
`}
`; }) .join(""); const renderLvmSection = () => { if (!lvmInfo) return ""; const pvs = Array.isArray(lvmInfo.pvs) ? lvmInfo.pvs : []; const vgs = Array.isArray(lvmInfo.vgs) ? lvmInfo.vgs : []; const lvs = Array.isArray(lvmInfo.lvs) ? lvmInfo.lvs : []; if (!pvs.length && !vgs.length && !lvs.length) return ""; const vgCards = vgs .slice(0, 12) .map((vg) => { const name = this.escapeHtml(String(vg.vg_name || vg.vg || vg.name || "VG")); const sizeRaw = vg.vg_size || vg.size || ""; const freeRaw = vg.vg_free || vg.free || ""; const sizeGb = parseHumanSizeToGb(sizeRaw); const freeGb = parseHumanSizeToGb(freeRaw); const pct = sizeGb !== null && freeGb !== null && sizeGb > 0 ? clampPct(((sizeGb - freeGb) / sizeGb) * 100) : null; const color = getPctColor(pct); return `
${name}
${[String(sizeRaw || ""), String(freeRaw ? `${freeRaw} free` : "")].filter(Boolean).join(" • ")}
${pct !== null ? `${pct.toFixed(0)}%` : ""}
${pct !== null ? `
` : "" }
`; }) .join(""); const lvCards = lvs .slice(0, 16) .map((lv) => { const vg = this.escapeHtml(String(lv.vg_name || lv.vg || "")); const name = this.escapeHtml(String(lv.lv_name || lv.lv || lv.name || "LV")); const size = this.escapeHtml(String(lv.lv_size || lv.size || "")); const path = this.escapeHtml(String(lv.lv_path || lv.path || "")); return `
${vg ? `${vg} / ` : ""}${name}
${[size, path].filter(Boolean).join(" • ")}
`; }) .join(""); const pvCards = pvs .slice(0, 16) .map((pv) => { const dev = this.escapeHtml(String(pv.pv_name || pv.pv || pv.device || "PV")); const vg = this.escapeHtml(String(pv.vg_name || pv.vg || "")); const size = this.escapeHtml(String(pv.pv_size || pv.size || "")); const free = this.escapeHtml(String(pv.pv_free || pv.free || "")); return `
${dev}
${[vg ? `VG ${vg}` : "", size, free ? `${free} free` : ""].filter(Boolean).join(" • ")}
`; }) .join(""); return `
LVM
${vgCards ? `
${vgCards}
` : ""} ${lvCards ? `
${lvCards}
` : ""} ${pvCards ? `
${pvCards}
` : ""}
`; }; const renderZfsSection = () => { if (!zfsInfo) return ""; const pools = Array.isArray(zfsInfo.pools) ? zfsInfo.pools : []; const datasets = Array.isArray(zfsInfo.datasets) ? zfsInfo.datasets : []; if (!pools.length && !datasets.length) return ""; const poolCards = pools .slice(0, 12) .map((p) => { const name = this.escapeHtml(String(p.name || "pool")); const size = this.escapeHtml(String(p.size || "")); const alloc = this.escapeHtml(String(p.alloc || "")); const free = this.escapeHtml(String(p.free || "")); const capRaw = String(p.cap || "").replace("%", ""); const pct = clampPct(capRaw); const color = getPctColor(pct); return `
${name}
${[alloc ? `${alloc} used` : "", free ? `${free} free` : "", size ? `${size} total` : ""].filter(Boolean).join(" • ")}
${pct !== null ? `${pct.toFixed(0)}%` : ""}
${pct !== null ? `
` : "" }
`; }) .join(""); const datasetCards = datasets .slice(0, 20) .map((d) => { const name = this.escapeHtml(String(d.name || "dataset")); const used = this.escapeHtml(String(d.used || "")); const avail = this.escapeHtml(String(d.avail || "")); const mount = this.escapeHtml(String(d.mountpoint || "")); return `
${name}
${[mount, used ? `${used} used` : "", avail ? `${avail} avail` : ""].filter(Boolean).join(" • ")}
`; }) .join(""); return `
ZFS
${poolCards ? `
${poolCards}
` : ""} ${datasetCards ? `
${datasetCards}
` : ""}
`; }; const mountCards = !disks.length && diskInfo.length ? diskInfo .slice(0, 24) .map((m) => { const mountRaw = String(m.mount || m.mount_point || ""); const mount = this.escapeHtml(mountRaw); const dev = this.escapeHtml(String(m.device || "")); const fs = this.escapeHtml(String(m.filesystem || m.fstype || "")); const pct = clampPct(m.usage_percent); const totalGb = m.total_gb !== undefined && m.total_gb !== null ? Number(m.total_gb) : null; const freeGb = m.free_gb !== undefined && m.free_gb !== null ? Number(m.free_gb) : null; const usedGb = totalGb !== null && freeGb !== null ? Math.max(0, totalGb - freeGb) : null; const color = getPctColor(pct); const lineLeft = [dev, fs].filter(Boolean).join(" • "); const lineRight = [usedGb !== null ? `${formatGb(usedGb)} used` : "", freeGb !== null ? `${formatGb(freeGb)} free` : "", totalGb !== null ? `${formatGb(totalGb)} total` : ""].filter(Boolean).join(" • "); return `
${mount || dev || "mount"}
${lineLeft}
${pct !== null ? `${pct.toFixed(0)}%` : ""}
${lineRight}
`; }) .join("") : ""; return `
Détails Disques Used / Free / Total
${diskCards || mountCards ? `
${diskCards || mountCards}
` : `
Aucun détail disponible
`} ${renderLvmSection()} ${renderZfsSection()}
`; }; // Temperature (if available) const tempHtml = metrics.cpu_temperature ? `
${metrics.cpu_temperature}°C
` : ""; // Uptime const uptimeHtml = metrics.uptime_human ? `${metrics.uptime_human}` : ""; return `
SYSTEM METRICS
${tempHtml ? `${metrics.cpu_temperature}°C` : ""} ${uptimeHtml ? `${metrics.uptime_human}` : ""} SYNC: ${lastCollected}
CPU ${cpuText}
${cpuDetailLine}
RAM ${memText}
${memDetail}
DISK ${diskText}
${diskDetail} ${diskCountText ? `• ${diskCountText.toUpperCase()}` : ""}
${showDiskDetails ? `
${renderDiskDevices()} ` : "" } ${this.renderStorageDetailsSection(metrics, effectiveHostId)}
`; } // Render detailed storage section (accordion) renderStorageDetailsSection(metrics, hostId) { const storageDetails = metrics?.storage_details; const isExpanded = this.expandedStorageDetails?.has(hostId) || false; // Use storage_details if available, otherwise fallback to existing metrics fields const hasStorageDetails = !!storageDetails; const status = storageDetails?.status || "unknown"; const osType = storageDetails?.os_type || metrics?.os_name || "unknown"; const flags = storageDetails?.feature_flags || {}; const summary = storageDetails?.summary || {}; // Filesystems: from storage_details or build from disk_info let filesystems = storageDetails?.filesystems || []; if (!filesystems.length && metrics?.disk_info?.length) { filesystems = metrics.disk_info.map((d) => ({ device: d.device || "", fstype: d.filesystem || d.fstype || "", mountpoint: d.mount || d.mount_point || "", size_bytes: d.total_gb ? d.total_gb * 1024 * 1024 * 1024 : 0, used_bytes: d.total_gb && d.free_gb ? (d.total_gb - d.free_gb) * 1024 * 1024 * 1024 : 0, avail_bytes: d.free_gb ? d.free_gb * 1024 * 1024 * 1024 : 0, use_pct: d.usage_percent || 0, })); } // Block devices from storage_details or disk_devices const blockDevices = storageDetails?.block_devices || metrics?.disk_devices || []; // ZFS from storage_details or zfs_info const zfsPools = storageDetails?.zfs?.pools || metrics?.zfs_info?.pools || []; const zfsDatasets = storageDetails?.zfs?.datasets || metrics?.zfs_info?.datasets || []; // LVM from storage_details or lvm_info const lvmVgs = storageDetails?.lvm?.vgs || metrics?.lvm_info?.vgs || []; const commandsRun = storageDetails?.commands_run || []; const partialFailures = storageDetails?.partial_failures || []; const collectedAt = storageDetails?.collected_at || ""; // Check if we have any data to show const hasData = filesystems.length > 0 || blockDevices.length > 0 || zfsPools.length > 0 || lvmVgs.length > 0; // Build summary chips const chips = []; if (filesystems.length) chips.push(`${filesystems.length} FS`); if (blockDevices.length) chips.push(`${blockDevices.length} disques`); if (zfsPools.length || flags.has_zfs) chips.push("ZFS"); if (lvmVgs.length || flags.has_lvm) chips.push("LVM"); // Calculate usage from summary or from filesystems let usedPct = summary.used_pct ? Number(summary.used_pct).toFixed(0) : null; let totalBytes = summary.total_bytes || 0; let usedBytes = summary.used_bytes || 0; // If no summary, calculate from filesystems (exclude virtual fs) if (!totalBytes && filesystems.length) { const realFs = filesystems.filter((fs) => { const mp = (fs.mountpoint || "").toLowerCase(); const dev = (fs.device || "").toLowerCase(); return !mp.startsWith("/run") && !mp.startsWith("/sys") && !mp.startsWith("/proc") && !dev.includes("tmpfs") && !dev.includes("devtmpfs"); }); totalBytes = realFs.reduce((sum, fs) => sum + (fs.size_bytes || 0), 0); usedBytes = realFs.reduce((sum, fs) => sum + (fs.used_bytes || 0), 0); if (totalBytes > 0) { usedPct = ((usedBytes / totalBytes) * 100).toFixed(0); } } const formatBytes = (bytes) => { if (!bytes || bytes <= 0) return ""; const gb = bytes / (1024 * 1024 * 1024); if (gb >= 1024) return `${(gb / 1024).toFixed(1)} TB`; if (gb >= 100) return `${gb.toFixed(0)} GB`; return `${gb.toFixed(1)} GB`; }; const summaryLine = chips.length ? chips.join(" • ") : "NO STORAGE DATA"; const usageLine = usedPct !== null ? `${usedPct}%` : ""; const sizeLine = totalBytes > 0 ? `${formatBytes(usedBytes)} / ${formatBytes(totalBytes)}` : ""; const statusLabel = status === "ok" ? 'SYMBOLS_OK' : status === "partial" ? 'PARTIAL_SYNC' : 'SYNC_ERROR'; const getPctColor = (pct) => { if (pct === null || pct === undefined) return "bg-white/10"; return pct >= 90 ? "bg-red-500" : pct >= 75 ? "bg-yellow-500" : "bg-cyan-500/50"; }; const renderFilesystemsTable = () => { if (!filesystems.length) return '
No filesystems detected
'; const filtered = filesystems.filter((fs) => { const mp = (fs.mountpoint || "").toLowerCase(); const dev = (fs.device || "").toLowerCase(); return !mp.startsWith("/run") && !mp.startsWith("/sys") && !mp.startsWith("/proc") && !dev.includes("tmpfs") && !dev.includes("devtmpfs"); }); return `
${filtered .slice(0, 20) .map((fs) => { const pct = fs.use_pct !== undefined ? Number(fs.use_pct) : null; const pctColor = pct >= 90 ? "text-red-400" : pct >= 75 ? "text-yellow-400" : "text-cyan-400/80"; const rowClass = pct >= 85 ? "bg-red-500/[0.03]" : ""; const rawDevice = fs.device || "-"; const deviceDisplay = typeof rawDevice === "string" && rawDevice.startsWith("/dev/") ? rawDevice.slice("/dev/".length) : rawDevice; return ` `; }) .join("")}
Mountpoint Device FS Capacity Used %
${this.escapeHtml(fs.mountpoint || "-")} ${this.escapeHtml(deviceDisplay || "-")} ${this.escapeHtml(fs.fstype || "-")} ${formatBytes(fs.size_bytes)} ${formatBytes(fs.used_bytes)} ${pct !== null ? pct + "%" : "-"}
`; }; const renderZfsSection = () => { if (!zfsPools.length && !zfsDatasets.length) return ""; const poolCards = zfsPools .map((pool) => { const pct = pool.cap_pct !== undefined ? Number(pool.cap_pct) : null; const color = getPctColor(pct); const healthColor = pool.health === "ONLINE" ? "text-green-400" : pool.health === "DEGRADED" ? "text-yellow-400" : "text-red-400"; return `
${this.escapeHtml(pool.name || "pool")} ${pool.health || "UNKNOWN"}
${formatBytes(pool.alloc_bytes)} / ${formatBytes(pool.size_bytes)} ${pct !== null ? pct + "%" : ""}
`; }) .join(""); const datasetsList = zfsDatasets .slice(0, 15) .map( (ds) => `
${this.escapeHtml(ds.name || "")} ${formatBytes(ds.used_bytes)} used
`, ) .join(""); return `
ZFS REPLICA ${zfsPools.length} POOLS • ${zfsDatasets.length} DATASETS
${poolCards ? `
${poolCards}
` : ""} ${zfsDatasets.length > 0 ? `
${datasetsList}
` : ""}
`; }; const renderLvmSection = () => { if (!lvmVgs.length) return ""; const vgCards = lvmVgs .map((vg) => { const name = vg.vg_name || vg.name || "VG"; const size = vg.vg_size || vg.size || ""; const free = vg.vg_free || vg.free || ""; return `
${this.escapeHtml(name)}
${size}${free ? ` • ${free} FREE` : ""}
`; }) .join(""); return `
LVM VOLUMES ${lvmVgs.length} VG INSTANCES
${vgCards}
`; }; const renderInspectorDrawer = () => { if (!this.storageInspectorOpen?.has(hostId)) return ""; const cmdListHtml = (commandsRun || []) .map( (cmd) => `
${this.escapeHtml(cmd.cmd || cmd)}
`, ) .join("") || '
NO COMMANDS EXECUTED
'; const failuresListHtml = (partial_failures || []) .map( (f) => `
${this.escapeHtml(f)}
`, ) .join(""); return `
Storage Inspector
Last Sample ${collectedAt ? new Date(collectedAt).toLocaleString("en-US", { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) : "-"}
Health Status ${statusLabel}
IO Stack Commands ${cmdListHtml}
${failuresListHtml ? `
Warnings & Alerts ${failuresListHtml}
` : "" }
`; }; // Don't show section if no data at all if (!hasData) { return ""; } // Status badge - show "Données existantes" if using fallback data const displayStatusBadge = hasStorageDetails ? statusBadge : "bg-blue-500/20 text-blue-400"; const displayStatusText = hasStorageDetails ? statusText : "Données"; return `
Detailed Storage ${statusLabel}
${chips.map((c) => `${c}`).join("")}
${usageLine}${sizeLine ? ` • ${sizeLine}` : ""} ${hasStorageDetails ? ` ` : "" }
${isExpanded ? `
${renderFilesystemsTable()} ${renderZfsSection()} ${renderLvmSection()}
` : "" } ${hasStorageDetails ? renderInspectorDrawer() : ""}
`; } // Toggle storage details accordion toggleStorageDetails(hostId) { if (!this.expandedStorageDetails) this.expandedStorageDetails = new Set(); if (this.expandedStorageDetails.has(hostId)) { this.expandedStorageDetails.delete(hostId); } else { this.expandedStorageDetails.add(hostId); } this.renderHosts(); } // Toggle storage inspector drawer toggleStorageInspector(hostId) { if (!this.storageInspectorOpen) this.storageInspectorOpen = new Set(); if (this.storageInspectorOpen.has(hostId)) { this.storageInspectorOpen.delete(hostId); } else { this.storageInspectorOpen.add(hostId); } this.renderHosts(); } // Collect metrics for all hosts async collectAllHostMetrics() { if (this.metricsLoading) { this.showNotification("Collecte déjà en cours...", "warning"); return; } this.metricsLoading = true; this.showNotification("Collecte des métriques en cours...", "info"); try { const result = await this.apiCall("/api/builtin-playbooks/collect-all", { method: "POST", }); if (result.success) { const message = result.message || `Collecte des métriques lancée pour ${result.hosts_count || 0} hôte(s)`; this.showNotification(message, "success"); } else { const errorMsg = result.error_detail || result.errors?.[0] || "Erreur inconnue"; console.error("Échec de la collecte:", errorMsg); this.showNotification(`Erreur lors de la collecte des métriques: ${errorMsg.substring(0, 100)}`, "error"); } } catch (error) { console.error("Error collecting metrics:", error); const errorMsg = error.detail && (error.detail.detail || error.detail.message || error.detail.error) ? error.detail.detail || error.detail.message || error.detail.error : error.message || "Erreur inconnue"; this.showNotification(`Erreur lors de la collecte des métriques: ${errorMsg}`, "error"); } finally { this.metricsLoading = false; } } async installBaseToolsAllHosts() { if (!confirm("Installer les outils de base requis sur tous les hôtes ?")) return; this.showNotification("Installation des outils de base programmée...", "info"); try { const result = await this.apiCall("/api/builtin-playbooks/execute-background", { method: "POST", body: JSON.stringify({ builtin_id: "install_base_tools", target: "all", }), }); if (result.task_id) { this.showNotification(`Installation lancée (tâche ${result.task_id})`, "success"); this.setActiveNav("tasks"); await this.loadTaskLogsWithFilters(); } else { this.showNotification("Installation programmée (voir Tasks/Logs pour le détail)", "success"); } } catch (error) { console.error("Error installing base tools:", error); this.showNotification(`Erreur lors de l'installation: ${error.detail || error.message || "Erreur inconnue"}`, "error"); } } // Load host metrics from API async loadHostMetrics() { try { this.hostMetrics = await this.apiCall("/api/monitoring/all-hosts").catch(() => ({})); } catch (error) { console.error("Error loading host metrics:", error); this.hostMetrics = {}; } } // Collect metrics for a single host async collectHostMetrics(hostName) { this.showNotification(`Collecte des métriques pour ${hostName}...`, "info"); try { const result = await this.apiCall("/api/builtin-playbooks/execute", { method: "POST", body: JSON.stringify({ builtin_id: "collect_system_info", target: hostName, }), }); if (result.success) { this.showNotification(`Métriques collectées pour ${hostName}`, "success"); await this.loadHostMetrics(); this.renderHosts(); } else { this.showNotification(`Erreur: ${result.error || "Échec de la collecte"}`, "error"); } } catch (error) { console.error("Error collecting host metrics:", error); this.showNotification("Erreur lors de la collecte des métriques", "error"); } } // Calcul de la qualité de communication d'un hôte getHostCommunicationQuality(host) { // Facteurs de qualité: // - Statut actuel (online/offline) // - Dernière vérification (last_seen) // - Bootstrap configuré // - Historique des tâches récentes (si disponible) let score = 0; let factors = []; // Statut online = +2 points if (host.status === "online") { score += 2; factors.push("En ligne"); } else if (host.status === "offline") { factors.push("Hors ligne"); } // Bootstrap OK = +1 point if (host.bootstrap_ok) { score += 1; factors.push("Ansible configuré"); } // Last seen récent = +2 points (moins de 1h), +1 point (moins de 24h) if (host.last_seen) { const lastSeenDate = new Date(host.last_seen); const now = new Date(); const hoursDiff = (now - lastSeenDate) / (1000 * 60 * 60); if (hoursDiff < 1) { score += 2; factors.push("Vérifié récemment"); } else if (hoursDiff < 24) { score += 1; factors.push("Vérifié aujourd'hui"); } else { factors.push("Non vérifié récemment"); } } else { factors.push("Jamais vérifié"); } // Convertir le score en niveau (1-5) const level = Math.min(5, Math.max(1, Math.round(score))); // Déterminer couleur et label selon le niveau let colorClass, textClass, label; if (level >= 4) { colorClass = "bg-green-500"; textClass = "text-green-400"; label = "Excellent"; } else if (level >= 3) { colorClass = "bg-yellow-500"; textClass = "text-yellow-400"; label = "Bon"; } else if (level >= 2) { colorClass = "bg-orange-500"; textClass = "text-orange-400"; label = "Moyen"; } else { colorClass = "bg-red-500"; textClass = "text-red-400"; label = "Faible"; } return { level, colorClass, textClass, label, tooltip: factors.join(" • "), }; } // Modal pour exécuter un playbook sur un hôte spécifique async showPlaybookModalForHost(hostName) { // Récupérer la liste des playbooks compatibles avec cet hôte try { const pbResult = await this.apiCall(`/api/ansible/playbooks?target=${encodeURIComponent(hostName)}`); const playbooks = pbResult && pbResult.playbooks ? pbResult.playbooks : []; const playbookOptions = playbooks .map( (p) => ` `, ) .join(""); const modalContent = `

Hôte cible

${this.escapeHtml(hostName)}

Seuls les playbooks compatibles avec cet hôte sont affichés (${playbooks.length} disponible${playbooks.length > 1 ? "s" : ""})
`; this.showModal("Exécuter un Playbook", modalContent); } catch (error) { this.showNotification(`Erreur chargement playbooks: ${error.message}`, "error"); } } async executePlaybookOnHost(hostName) { const playbookSelect = document.getElementById("playbook-select"); const extraVarsInput = document.getElementById("playbook-extra-vars"); const checkModeInput = document.getElementById("playbook-check-mode"); const playbook = playbookSelect?.value; if (!playbook) { this.showNotification("Veuillez sélectionner un playbook", "warning"); return; } let extraVars = {}; if (extraVarsInput?.value.trim()) { try { extraVars = JSON.parse(extraVarsInput.value); } catch (e) { this.showNotification("Variables JSON invalides", "error"); return; } } const checkMode = checkModeInput?.checked || false; this.closeModal(); if (this._playbookLaunchInFlight) { this.showNotification("Une exécution de playbook est déjà en cours de lancement", "info"); return; } this._playbookLaunchInFlight = true; this.showNotification("Lancement du playbook en arrière-plan...", "info"); try { const result = await this.apiCall("/api/ansible/execute", { method: "POST", body: JSON.stringify({ playbook: playbook, target: hostName, extra_vars: extraVars, check_mode: checkMode, }), }); this.showNotification(`Playbook "${playbook}" lancé sur ${hostName} (tâche ${result.task_id})`, "success"); } catch (error) { this.showNotification(`Erreur: ${error.message}`, "error"); } finally { this._playbookLaunchInFlight = false; } } async refreshHosts() { this.showLoading(); try { await this.apiCall("/api/hosts/refresh", { method: "POST" }); await this.loadAllData(); this.hideLoading(); this.showNotification("Hôtes rechargés depuis l'inventaire Ansible", "success"); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } async syncHostsFromAnsible() { this.showLoading(); try { const result = await this.apiCall("/api/hosts/sync", { method: "POST" }); await this.loadAllData(); this.hideLoading(); // Afficher un résumé détaillé const created = result.created?.length || 0; const skipped = result.skipped?.length || 0; const errors = result.errors?.length || 0; if (created > 0) { this.showNotification(`Import réussi: ${created} hôte(s) importé(s), ${skipped} déjà existant(s)`, "success"); } else if (skipped > 0) { this.showNotification(`Tous les hôtes sont déjà importés (${skipped} hôte(s))`, "info"); } else { this.showNotification("Aucun hôte trouvé dans l'inventaire Ansible", "warning"); } if (errors > 0) { console.error("Erreurs lors de l'import:", result.errors); this.showNotification(`${errors} erreur(s) lors de l'import`, "error"); } } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } filterHostsByGroup(group) { this.currentGroupFilter = group; this.renderHosts(); } // ===== GESTION DES HÔTES (CRUD) ===== async loadHostGroups() { try { const result = await this.apiCall("/api/hosts/groups"); this.envGroups = result.env_groups || []; this.roleGroups = result.role_groups || []; return result; } catch (error) { console.error("Erreur chargement groupes:", error); return { env_groups: [], role_groups: [] }; } } async showAddHostModal() { // Charger les groupes disponibles await this.loadHostGroups(); const envOptions = this.envGroups.map((g) => ``).join(""); const roleCheckboxes = this.roleGroups .map( (g) => ` `, ) .join(""); this.showModal( "Ajouter un Host", `
L'hôte sera ajouté au fichier hosts.yml
${roleCheckboxes || '

Aucun groupe de rôle disponible

'}
`, ); } async createHost(event) { event.preventDefault(); const formData = new FormData(event.target); const envGroup = formData.get("env_group"); if (!envGroup) { this.showNotification("Veuillez sélectionner un groupe d'environnement", "error"); return; } // Récupérer les rôles sélectionnés const roleGroups = []; document.querySelectorAll('input[name="role_groups"]:checked').forEach((cb) => { roleGroups.push(cb.value); }); const payload = { name: formData.get("name"), ip: formData.get("ip") || null, env_group: envGroup, role_groups: roleGroups, }; this.closeModal(); this.showLoading(); try { const result = await this.apiCall("/api/hosts", { method: "POST", body: JSON.stringify(payload), }); this.hideLoading(); this.showNotification(`Hôte "${payload.name}" ajouté avec succès dans hosts.yml`, "success"); // Recharger les données await this.loadAllData(); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } async showEditHostModal(hostName) { // Trouver l'hôte const host = this.hosts.find((h) => h.name === hostName); if (!host) { this.showNotification("Hôte non trouvé", "error"); return; } // Charger les groupes disponibles await this.loadHostGroups(); // Tenter de récupérer les groupes depuis l'inventaire Ansible (source de vérité) let inventoryGroups = []; const ansibleHost = (this.ansibleHosts || []).find((h) => h.name === hostName); if (ansibleHost && Array.isArray(ansibleHost.groups)) { inventoryGroups = ansibleHost.groups; } else if (Array.isArray(host.groups)) { // Fallback sur les groupes renvoyés par l'API des hosts inventoryGroups = host.groups; } // Identifier le groupe d'environnement et les groupes de rôles actuels const currentEnvGroup = inventoryGroups.find((g) => g.startsWith("env_")) || ""; const currentRoleGroups = inventoryGroups.filter((g) => g.startsWith("role_")); const envOptions = this.envGroups.map((g) => ``).join(""); const roleCheckboxes = this.roleGroups .map( (g) => ` `, ) .join(""); this.showModal( `Modifier: ${hostName}`, `
Les modifications seront appliquées au fichier hosts.yml

${hostName}

${host.ip}

${roleCheckboxes || '

Aucun groupe de rôle disponible

'}
`, ); } async updateHost(event, hostName) { event.preventDefault(); const formData = new FormData(event.target); // Récupérer les rôles sélectionnés const roleGroups = []; document.querySelectorAll('input[name="role_groups"]:checked').forEach((cb) => { roleGroups.push(cb.value); }); const payload = { env_group: formData.get("env_group") || null, role_groups: roleGroups, ansible_host: formData.get("ansible_host") || null, }; this.closeModal(); this.showLoading(); try { const result = await this.apiCall(`/api/hosts/${encodeURIComponent(hostName)}`, { method: "PUT", body: JSON.stringify(payload), }); this.hideLoading(); this.showNotification(`Hôte "${hostName}" mis à jour avec succès`, "success"); // Recharger les données await this.loadAllData(); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } confirmDeleteHost(hostName) { this.showModal( "Confirmer la suppression", `

Attention !

Vous êtes sur le point de supprimer l'hôte ${hostName} de l'inventaire Ansible.

Cette action supprimera l'hôte de tous les groupes et ne peut pas être annulée.

`, ); } async deleteHost(hostName) { this.closeModal(); this.showLoading(); try { await this.apiCall(`/api/hosts/by-name/${encodeURIComponent(hostName)}`, { method: "DELETE", }); this.hideLoading(); this.showNotification(`Hôte "${hostName}" supprimé avec succès`, "success"); // Recharger les données await this.loadAllData(); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } // ===== GESTION DES GROUPES (CRUD) ===== async loadGroups() { try { const result = await this.apiCall("/api/groups"); return result; } catch (error) { console.error("Erreur chargement groupes:", error); return { groups: [], env_count: 0, role_count: 0 }; } } showAddGroupModal(type) { const typeLabel = type === "env" ? "environnement" : "rôle"; const prefix = type === "env" ? "env_" : "role_"; const icon = type === "env" ? "fa-globe" : "fa-tags"; const color = type === "env" ? "green" : "blue"; this.showModal( `Ajouter un groupe d'${typeLabel}`, `
Le groupe sera ajouté à l'inventaire Ansible avec le préfixe ${prefix}
${prefix}
`, ); } async createGroup(event, type) { event.preventDefault(); const formData = new FormData(event.target); const payload = { name: formData.get("name"), type: type, }; this.closeModal(); this.showLoading(); try { const result = await this.apiCall("/api/groups", { method: "POST", body: JSON.stringify(payload), }); this.hideLoading(); this.showNotification(result.message || `Groupe créé avec succès`, "success"); // Recharger les groupes await this.loadHostGroups(); await this.loadAllData(); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } async showManageGroupsModal(type) { const typeLabel = type === "env" ? "environnement" : "rôle"; const typeLabelPlural = type === "env" ? "environnements" : "rôles"; const icon = type === "env" ? "fa-globe" : "fa-tags"; const color = type === "env" ? "green" : "blue"; // Charger les groupes const groupsData = await this.loadGroups(); const groups = groupsData.groups.filter((g) => g.type === type); let groupsHtml = ""; if (groups.length === 0) { groupsHtml = `

Aucun groupe d'${typeLabel} trouvé

`; } else { groupsHtml = `
${groups .map( (g) => `

${g.display_name}

${g.name} ${g.hosts_count} hôte(s)

`, ) .join("")}
`; } this.showModal( `Gérer les ${typeLabelPlural}`, `

${groups.length} groupe(s) d'${typeLabel}

${groupsHtml}
`, ); } async showEditGroupModal(groupName, type) { const typeLabel = type === "env" ? "environnement" : "rôle"; const prefix = type === "env" ? "env_" : "role_"; const icon = type === "env" ? "fa-globe" : "fa-tags"; const color = type === "env" ? "green" : "blue"; const displayName = groupName.replace(prefix, ""); this.showModal( `Modifier le groupe: ${displayName}`, `
Le renommage affectera tous les hôtes associés à ce groupe.
${prefix}
`, ); } async updateGroup(event, groupName, type) { event.preventDefault(); const formData = new FormData(event.target); const payload = { new_name: formData.get("new_name"), }; this.closeModal(); this.showLoading(); try { const result = await this.apiCall(`/api/groups/${encodeURIComponent(groupName)}`, { method: "PUT", body: JSON.stringify(payload), }); this.hideLoading(); this.showNotification(result.message || `Groupe modifié avec succès`, "success"); // Recharger les groupes et afficher la liste await this.loadHostGroups(); await this.loadAllData(); this.showManageGroupsModal(type); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } async confirmDeleteGroup(groupName, type, hostsCount) { const typeLabel = type === "env" ? "environnement" : "rôle"; const prefix = type === "env" ? "env_" : "role_"; const displayName = groupName.replace(prefix, ""); // Charger les autres groupes du même type pour le déplacement const groupsData = await this.loadGroups(); const otherGroups = groupsData.groups.filter((g) => g.type === type && g.name !== groupName); let moveOptions = ""; if (hostsCount > 0 && type === "env") { // Pour les groupes d'environnement avec des hôtes, on doit proposer un déplacement moveOptions = `

Les ${hostsCount} hôte(s) seront déplacés vers ce groupe.

`; } this.showModal( "Confirmer la suppression", `

Attention !

Vous êtes sur le point de supprimer le groupe d'${typeLabel} ${displayName}.

${hostsCount > 0 ? `

Ce groupe contient ${hostsCount} hôte(s).

` : "" }
${moveOptions}
`, ); } async deleteGroup(groupName, type) { // Récupérer le groupe de destination si spécifié const moveSelect = document.getElementById("move-hosts-to"); const moveHostsTo = moveSelect ? moveSelect.value : null; this.closeModal(); this.showLoading(); try { let url = `/api/groups/${encodeURIComponent(groupName)}`; if (moveHostsTo) { url += `?move_hosts_to=${encodeURIComponent(moveHostsTo)}`; } const result = await this.apiCall(url, { method: "DELETE", }); this.hideLoading(); this.showNotification(result.message || `Groupe supprimé avec succès`, "success"); // Recharger les groupes et afficher la liste await this.loadHostGroups(); await this.loadAllData(); this.showManageGroupsModal(type); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } async executePlaybookOnGroup() { const currentGroup = this.currentGroupFilter; // Charger les playbooks compatibles avec ce groupe let compatiblePlaybooks = []; try { const pbResult = await this.apiCall(`/api/ansible/playbooks?target=${encodeURIComponent(currentGroup)}`); compatiblePlaybooks = pbResult && pbResult.playbooks ? pbResult.playbooks : []; } catch (error) { this.showNotification(`Erreur chargement playbooks: ${error.message}`, "error"); return; } // Générer la liste des playbooks groupés par catégorie const categoryColors = { maintenance: "text-orange-400", monitoring: "text-green-400", backup: "text-blue-400", general: "text-gray-400", testing: "text-purple-400", }; let playbooksByCategory = {}; compatiblePlaybooks.forEach((pb) => { const cat = pb.category || "general"; if (!playbooksByCategory[cat]) playbooksByCategory[cat] = []; playbooksByCategory[cat].push(pb); }); let playbooksHtml = ""; Object.entries(playbooksByCategory).forEach(([category, playbooks]) => { const colorClass = categoryColors[category] || "text-gray-400"; playbooksHtml += `
${category}
${playbooks .map( (pb) => ` `, ) .join("")}
`; }); this.showModal( `Exécuter un Playbook sur "${currentGroup === "all" ? "Tous les hôtes" : currentGroup}"`, `
Sélectionnez un playbook à exécuter sur ${currentGroup === "all" ? "tous les hôtes" : "le groupe " + currentGroup}
Seuls les playbooks compatibles avec ce ${currentGroup === "all" ? "groupe" : "groupe"} sont affichés (${compatiblePlaybooks.length} disponible${compatiblePlaybooks.length > 1 ? "s" : ""})
${playbooksHtml || '

Aucun playbook disponible

'}
`, ); } async runPlaybookOnTarget(playbook, target) { this.closeModal(); if (this._playbookLaunchInFlight) { this.showNotification("Une exécution de playbook est déjà en cours de lancement", "info"); return; } this._playbookLaunchInFlight = true; this.showNotification("Lancement du playbook en arrière-plan...", "info"); try { const result = await this.apiCall("/api/ansible/execute", { method: "POST", body: JSON.stringify({ playbook: playbook, target: target === "all" ? "all" : target, check_mode: false, verbose: true, }), }); this.showNotification(`Playbook ${playbook} lancé sur ${target} (tâche ${result.task_id})`, "success"); } catch (error) { this.showNotification(`Erreur: ${error.message}`, "error"); } finally { this._playbookLaunchInFlight = false; } } renderTasks() { const container = document.getElementById("tasks-list"); if (!container) return; // Combiner les tâches en mémoire avec les logs markdown const runningTasks = this.tasks.filter((t) => t.status === "running" || t.status === "pending"); // Filtrer les logs selon les filtres actifs let filteredLogs = this.taskLogs; if (this.currentStatusFilter && this.currentStatusFilter !== "all") { filteredLogs = filteredLogs.filter((log) => log.status === this.currentStatusFilter); } // Générer les options de catégories const categoryOptions = Object.keys(this.playbookCategories) .map((cat) => ``) .join(""); // Générer les options de sous-catégories selon la catégorie sélectionnée let subcategoryOptions = ""; if (this.currentCategoryFilter !== "all" && this.playbookCategories[this.currentCategoryFilter]) { subcategoryOptions = this.playbookCategories[this.currentCategoryFilter].map((sub) => ``).join(""); } // Générer les options de target (groupes + hôtes) const groupOptions = this.ansibleGroups.map((g) => ``).join(""); const hostOptions = this.hosts.map((h) => ``).join(""); // Catégories dynamiques pour le filtre const taskCategories = ["Playbook", "Ad-hoc", "Autre"]; const taskCategoryOptions = taskCategories.map((cat) => ``).join(""); // Types de source pour le filtre const sourceTypes = [ { value: "scheduled", label: "Planifiés" }, { value: "manual", label: "Manuels" }, { value: "adhoc", label: "Ad-hoc" }, ]; const sourceTypeOptions = sourceTypes.map((st) => ``).join(""); // Vérifier si des filtres sont actifs const hasActiveFilters = (this.currentTargetFilter && this.currentTargetFilter !== "all") || (this.currentCategoryFilter && this.currentCategoryFilter !== "all") || (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== "all") || this.currentHourStart || this.currentHourEnd; // Labels pour les types de source const sourceTypeLabels = { scheduled: "Planifiés", manual: "Manuels", adhoc: "Ad-hoc" }; // Générer les badges de filtres actifs // Active filters badges in MCP style const activeFiltersHtml = hasActiveFilters ? `
Active Sorters: ${this.currentTargetFilter && this.currentTargetFilter !== "all" ? ` ${this.escapeHtml(this.currentTargetFilter)} ` : "" } ${this.currentCategoryFilter && this.currentCategoryFilter !== "all" ? ` ${this.escapeHtml(this.currentCategoryFilter)} ` : "" } ${this.currentSourceTypeFilter && this.currentSourceTypeFilter !== "all" ? ` ${sourceTypeLabels[this.currentSourceTypeFilter] || this.currentSourceTypeFilter.toUpperCase()} ` : "" } ${this.currentHourStart || this.currentHourEnd ? ` ${this.currentHourStart || "00:00"} - ${this.currentHourEnd || "23:59"} ` : "" }
` : ""; // Container structure with MCP styled controls const headerHtml = `
Records Loaded ${filteredLogs.length} ENTRIES
${runningTasks.length > 0 ? `
Active Processes ${runningTasks.length} RUNNING
` : "" }
Node Target
Logic Segment
Origin Stream
${activeFiltersHtml}
`; container.innerHTML = headerHtml; // Afficher d'abord les tâches en cours (section dynamique) if (runningTasks.length > 0) { const runningSection = document.createElement("div"); runningSection.className = "running-tasks-section mb-6"; runningSection.innerHTML = '

Active Stream

'; const tasksContainer = document.createElement("div"); tasksContainer.className = "running-tasks-list space-y-3"; tasksContainer.innerHTML = runningTasks.map((task) => this.createRunningTaskHTML(task)).join(""); runningSection.appendChild(tasksContainer); container.appendChild(runningSection); } // Afficher les logs markdown if (filteredLogs.length > 0) { const logsSection = document.createElement("div"); logsSection.id = "task-logs-section"; logsSection.innerHTML = '

System Archives

'; // Afficher tous les logs chargés (pagination côté serveur) filteredLogs.forEach((log) => { logsSection.appendChild(this.createTaskLogCard(log)); }); container.appendChild(logsSection); // Afficher la pagination si nécessaire (basée sur la pagination serveur) const paginationEl = document.getElementById("tasks-pagination"); if (paginationEl) { if (this.tasksHasMore) { paginationEl.classList.remove("hidden"); const remaining = this.tasksTotalCount - this.taskLogs.length; paginationEl.innerHTML = `
`; } else { paginationEl.classList.add("hidden"); } } } else if (runningTasks.length === 0) { container.innerHTML += `

Zero Records Found

Initiate ad-hoc uplink or rapid action to populate stream

`; } } async loadMoreTasks() { // Charger plus de tâches depuis le serveur (pagination côté serveur) const params = new URLSearchParams(); if (this.currentStatusFilter && this.currentStatusFilter !== "all") { params.append("status", this.currentStatusFilter); } if (this.selectedTaskDates && this.selectedTaskDates.length > 0) { const firstDate = this.parseDateKey(this.selectedTaskDates[0]); params.append("year", String(firstDate.getFullYear())); params.append("month", String(firstDate.getMonth() + 1).padStart(2, "0")); params.append("day", String(firstDate.getDate()).padStart(2, "0")); } else { if (this.currentDateFilter.year) params.append("year", this.currentDateFilter.year); if (this.currentDateFilter.month) params.append("month", this.currentDateFilter.month); if (this.currentDateFilter.day) params.append("day", this.currentDateFilter.day); } if (this.currentHourStart) params.append("hour_start", this.currentHourStart); if (this.currentHourEnd) params.append("hour_end", this.currentHourEnd); if (this.currentTargetFilter && this.currentTargetFilter !== "all") { params.append("target", this.currentTargetFilter); } if (this.currentCategoryFilter && this.currentCategoryFilter !== "all") { params.append("category", this.currentCategoryFilter); } if (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== "all") { params.append("source_type", this.currentSourceTypeFilter); } params.append("limit", this.tasksPerPage); params.append("offset", this.taskLogs.length); try { const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`); const newLogs = result.logs || []; // Ajouter les nouveaux logs à la liste existante this.taskLogs = [...this.taskLogs, ...newLogs]; this.tasksTotalCount = result.total_count || this.tasksTotalCount; this.tasksHasMore = result.has_more || false; this.tasksDisplayedCount = this.taskLogs.length; // Récupérer la section des logs const logsSection = document.getElementById("task-logs-section"); if (!logsSection) { this.renderTasks(); return; } // Ajouter les nouvelles tâches au DOM for (const log of newLogs) { logsSection.appendChild(this.createTaskLogCard(log)); } // Mettre à jour le bouton de pagination const paginationEl = document.getElementById("tasks-pagination"); if (paginationEl) { if (this.tasksHasMore) { const remaining = this.tasksTotalCount - this.taskLogs.length; paginationEl.innerHTML = ` `; } else { paginationEl.classList.add("hidden"); } } } catch (error) { console.error("Erreur chargement logs supplémentaires:", error); } } createTaskLogCard(log) { const statusColors = { completed: "border-green-500/20", failed: "border-red-500/20", running: "border-blue-500/20", pending: "border-yellow-500/20", }; const statusIcons = { completed: '', failed: '', running: '', pending: '', }; const formatTime = (isoString) => { if (!isoString || isoString === "N/A") return null; try { const date = new Date(isoString); if (isNaN(date.getTime())) return null; return date.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); } catch { return null; } }; const formatDuration = (seconds) => { if (!seconds || seconds <= 0) return null; if (seconds < 60) return `${seconds}s`; const mins = Math.floor(seconds / 60); const secs = seconds % 60; return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; }; const startTime = formatTime(log.start_time); const duration = log.duration_seconds ? formatDuration(log.duration_seconds) : log.duration && log.duration !== "N/A" ? log.duration : null; const hostsHtml = log.hosts && log.hosts.length > 0 ? `
${log.hosts .slice(0, 10) .map( (host) => ` ${this.escapeHtml(host)} `, ) .join("")} ${log.hosts.length > 10 ? `+${log.hosts.length - 10} more` : ""}
` : ""; const card = document.createElement("div"); card.className = `pro-card group hover:scale-[1.005] transition-all p-4 bg-[#13161e] border border-white/5 mb-3 cursor-pointer ${statusColors[log.status] || ""}`; card.onclick = () => this.viewTaskLogContent(log.id); const categoryLabel = (log.category || "TASK").toUpperCase(); card.innerHTML = `

${this.escapeHtml(log.task_name)}

${this.getStatusBadge(log.status)} ${categoryLabel}
${log.date} ${log.target ? ` ${this.escapeHtml(log.target)}` : ""} ${startTime ? ` ${startTime}` : ""} ${duration ? ` ${duration}` : ""}
${hostsHtml}
`; return card; } // Nouvelles fonctions de filtrage par clic filterByHost(host) { this.currentTargetFilter = host; this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); this.showNotification(`Filtre appliqué: ${host}`, "info"); } filterByTarget(target) { this.currentTargetFilter = target; this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); this.showNotification(`Filtre appliqué: ${target}`, "info"); } filterByCategory(category) { this.currentCategoryFilter = category; this.currentSubcategoryFilter = "all"; this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); this.showNotification(`Filtre catégorie: ${category}`, "info"); } async viewTaskLogContent(logId) { try { const result = await this.apiCall(`/api/tasks/logs/${logId}`); const parsed = this.parseTaskLogMarkdown(result.content); // Détecter si c'est une sortie de playbook structurée const isPlaybookOutput = parsed.output && (parsed.output.includes("PLAY [") || parsed.output.includes("TASK [") || parsed.output.includes("PLAY RECAP")); // Si c'est un playbook, utiliser la vue structurée if (isPlaybookOutput) { const parsedPlaybook = this.parseAnsiblePlaybookOutput(parsed.output); this.currentParsedOutput = parsedPlaybook; this.currentTaskLogRawOutput = parsed.output; // Mémoriser les métadonnées et le titre pour pouvoir revenir au résumé this.currentStructuredPlaybookMetadata = { duration: parsed.duration || "N/A", date: result.log.date || "", target: result.log.target || parsed.target || "N/A", }; this.currentStructuredPlaybookTitle = `Log: ${result.log.task_name}`; this.showStructuredPlaybookViewModal(); return; } // Sinon, utiliser la vue structurée ad-hoc (similaire aux playbooks) const hostOutputs = this.parseOutputByHost(parsed.output); this.currentTaskLogHostOutputs = hostOutputs; // Compter les succès/échecs const successCount = hostOutputs.filter((h) => h.status === "changed" || h.status === "success").length; const failedCount = hostOutputs.filter((h) => h.status === "failed" || h.status === "unreachable").length; const totalHosts = hostOutputs.length; // Utiliser la vue structurée ad-hoc si plusieurs hôtes if (totalHosts > 0) { const isSuccess = result.log.status === "completed"; const adHocView = this.renderAdHocStructuredView(hostOutputs, { taskName: result.log.task_name, target: result.log.target || parsed.target || "N/A", duration: parsed.duration || "N/A", returnCode: parsed.returnCode, date: result.log.date || "", isSuccess: isSuccess, error: parsed.error, }); this.currentAdHocMetadata = { taskName: result.log.task_name, target: result.log.target || parsed.target || "N/A", duration: parsed.duration || "N/A", returnCode: parsed.returnCode, date: result.log.date || "", isSuccess: isSuccess, error: parsed.error, }; this.currentAdHocTitle = `Log: ${result.log.task_name}`; this.showModal( this.currentAdHocTitle, `
${adHocView}
`, ); return; } // Déterminer le statut global const isSuccess = result.log.status === "completed"; const statusConfig = { completed: { icon: "fa-check-circle", color: "green", text: "Succès" }, failed: { icon: "fa-times-circle", color: "red", text: "Échoué" }, running: { icon: "fa-spinner fa-spin", color: "blue", text: "En cours" }, pending: { icon: "fa-clock", color: "yellow", text: "En attente" }, }; const status = statusConfig[result.log.status] || statusConfig.failed; // Générer les onglets des hôtes let hostTabsHtml = ""; if (hostOutputs.length > 1 || (hostOutputs.length === 1 && hostOutputs[0].hostname !== "output")) { hostTabsHtml = `
Sortie par hôte (${totalHosts} hôtes: ${successCount} OK${failedCount > 0 ? `, ${failedCount} échec` : ""})
${hostOutputs .map((host, index) => { const hostStatusColor = host.status === "changed" || host.status === "success" ? "bg-green-600/80 hover:bg-green-500 border-green-500" : host.status === "failed" || host.status === "unreachable" ? "bg-red-600/80 hover:bg-red-500 border-red-500" : "bg-gray-600/80 hover:bg-gray-500 border-gray-500"; const hostStatusIcon = host.status === "changed" || host.status === "success" ? "fa-check" : host.status === "failed" || host.status === "unreachable" ? "fa-times" : "fa-minus"; return ` `; }) .join("")}
`; } // Contenu du modal amélioré const modalContent = `

Résultat d'exécution

${status.text} • Cible: ${this.escapeHtml(result.log.target || parsed.target || "N/A")}

${parsed.duration || "N/A"}
${parsed.returnCode !== undefined ? `
Code: ${parsed.returnCode}
` : "" }
${hostTabsHtml}
Sortie
${this.formatAnsibleOutput(hostOutputs.length > 0 ? hostOutputs[0].output : parsed.output, isSuccess)}
${parsed.error ? `
Erreurs
${this.escapeHtml(parsed.error)}
` : "" }
`; this.showModal(`Log: ${result.log.task_name}`, modalContent); // Stocker la sortie brute pour la copie this.currentTaskLogRawOutput = parsed.output; } catch (error) { this.showNotification(`Erreur: ${error.message}`, "error"); } } showStructuredPlaybookViewModal() { if (!this.currentParsedOutput) return; const metadata = this.currentStructuredPlaybookMetadata || {}; const structuredView = this.renderStructuredPlaybookView(this.currentParsedOutput, metadata); const title = this.currentStructuredPlaybookTitle || "Résultat Playbook"; const rawOutput = this.currentTaskLogRawOutput || ""; this.showModal( title, `
${structuredView}
`, ); // Remplir la sortie brute formatée setTimeout(() => { const rawEl = document.getElementById("ansible-raw-output"); if (rawEl) { rawEl.innerHTML = this.formatAnsibleOutput(rawOutput, true); } }, 100); } returnToStructuredPlaybookView() { this.showStructuredPlaybookViewModal(); } renderAdHocStructuredView(hostOutputs, metadata) { /** * Génère une vue structurée pour les commandes ad-hoc (similaire aux playbooks) */ const isSuccess = metadata.isSuccess; const totalHosts = hostOutputs.length; const successCount = hostOutputs.filter((h) => h.status === "changed" || h.status === "success").length; const failedCount = hostOutputs.filter((h) => h.status === "failed" || h.status === "unreachable").length; const successRate = totalHosts > 0 ? Math.round((successCount / totalHosts) * 100) : 0; // Générer les cartes d'hôtes const hostCardsHtml = hostOutputs .map((host) => { const isFailed = host.status === "failed" || host.status === "unreachable"; const hasChanges = host.status === "changed"; let statusClass, statusIcon; if (isFailed) { statusClass = "border-red-500/50 bg-red-900/20"; statusIcon = ''; } else if (hasChanges) { statusClass = "border-yellow-500/50 bg-yellow-900/20"; statusIcon = ''; } else { statusClass = "border-green-500/50 bg-green-900/20"; statusIcon = ''; } const hostStatus = isFailed ? "failed" : hasChanges ? "changed" : "ok"; return `
${statusIcon} ${this.escapeHtml(host.hostname)}
${host.status.toUpperCase()}
${this.escapeHtml(host.output.substring(0, 60))}${host.output.length > 60 ? "..." : ""}
`; }) .join(""); return `
Commande Ad-Hoc

${this.escapeHtml(metadata.taskName || "Commande Ansible")}

${totalHosts} hôte(s) Cible: ${this.escapeHtml(metadata.target)} ${metadata.duration}

${isSuccess ? "✓" : "✗"} ${isSuccess ? "SUCCESS" : "FAILED"}
${metadata.date || ""}
${metadata.returnCode !== undefined ? `
Code: ${metadata.returnCode}
` : "" }
OK
${successCount}
Changed
${hostOutputs.filter((h) => h.status === "changed").length}
Failed
${failedCount}
Success Rate
${successRate}%

État des Hôtes

${hostCardsHtml}
${metadata.error ? `
Erreurs détectées
${this.escapeHtml(metadata.error)}
` : "" }
`; } showAdHocHostDetails(hostname) { const hostOutputs = this.currentTaskLogHostOutputs || []; const host = hostOutputs.find((h) => h.hostname === hostname); if (!host) { this.showNotification("Hôte non trouvé", "error"); return; } const isFailed = host.status === "failed" || host.status === "unreachable"; const hasChanges = host.status === "changed"; let statusClass, statusIcon, statusText; if (isFailed) { statusClass = "bg-red-900/30 border-red-700/50"; statusIcon = ''; statusText = 'FAILED'; } else if (hasChanges) { statusClass = "bg-yellow-900/30 border-yellow-700/50"; statusIcon = ''; statusText = 'CHANGED'; } else { statusClass = "bg-green-900/30 border-green-700/50"; statusIcon = ''; statusText = 'OK'; } const content = `

${this.escapeHtml(hostname)}

Statut: ${statusText}
${statusIcon}
Sortie
${this.formatAnsibleOutput(host.output, !isFailed)}
`; this.showModal(`Détails: ${hostname}`, content); } returnToAdHocView() { if (!this.currentTaskLogHostOutputs || !this.currentAdHocMetadata) return; const adHocView = this.renderAdHocStructuredView(this.currentTaskLogHostOutputs, this.currentAdHocMetadata); const title = this.currentAdHocTitle || "Résultat Ad-Hoc"; this.showModal( title, `
${adHocView}
`, ); } filterAdHocViewByStatus(status) { const cards = document.querySelectorAll(".adhoc-host-cards-grid .host-card-item"); const buttons = document.querySelectorAll(".host-status-section .av-filter-btn"); buttons.forEach((btn) => { btn.classList.remove("active", "bg-purple-600/50", "text-white"); btn.classList.add("bg-gray-700/50", "text-gray-400"); }); const activeBtn = document.querySelector(`.host-status-section .av-filter-btn[data-filter="${status}"]`); if (activeBtn) { activeBtn.classList.remove("bg-gray-700/50", "text-gray-400"); activeBtn.classList.add("active", "bg-purple-600/50", "text-white"); } cards.forEach((card) => { const cardStatus = card.dataset.status; if (status === "all" || cardStatus === status) { card.style.display = ""; } else { card.style.display = "none"; } }); } parseTaskLogMarkdown(content) { // Parser le contenu markdown pour extraire les métadonnées const result = { taskName: "", id: "", target: "", status: "", progress: 0, startTime: "", endTime: "", duration: "", output: "", error: "", returnCode: undefined, }; // Extraire le nom de la tâche du titre const titleMatch = content.match(/^#\s*[✅❌🔄⏳🚫❓]?\s*(.+)$/m); if (titleMatch) result.taskName = titleMatch[1].trim(); // Extraire les valeurs de la table d'informations const tablePatterns = { id: /\|\s*\*\*ID\*\*\s*\|\s*`([^`]+)`/, target: /\|\s*\*\*Cible\*\*\s*\|\s*`([^`]+)`/, status: /\|\s*\*\*Statut\*\*\s*\|\s*(\w+)/, progress: /\|\s*\*\*Progression\*\*\s*\|\s*(\d+)%/, startTime: /\|\s*\*\*Début\*\*\s*\|\s*([^|]+)/, endTime: /\|\s*\*\*Fin\*\*\s*\|\s*([^|]+)/, duration: /\|\s*\*\*Durée\*\*\s*\|\s*([^|]+)/, }; for (const [key, pattern] of Object.entries(tablePatterns)) { const match = content.match(pattern); if (match) { if (key === "progress") { result[key] = parseInt(match[1]); } else { result[key] = match[1].trim(); } } } // Extraire la sortie const outputMatch = content.match(/## Sortie\s*```([\s\S]*?)```/m); if (outputMatch) { result.output = outputMatch[1].trim(); // Essayer d'extraire le return code de la sortie const rcMatch = result.output.match(/rc=(\d+)/); if (rcMatch) { result.returnCode = parseInt(rcMatch[1]); } } // Extraire les erreurs const errorMatch = content.match(/## Erreurs\s*```([\s\S]*?)```/m); if (errorMatch) { result.error = errorMatch[1].trim(); } return result; } // ===== PARSER ANSIBLE INTELLIGENT ===== parseAnsiblePlaybookOutput(output) { /** * Parser intelligent pour extraire la structure complète d'une exécution Ansible * Retourne: { plays: [], hosts: {}, recap: {}, metadata: {} } */ const result = { metadata: { configFile: "", playbookName: "", executionTime: null, totalDuration: 0, }, plays: [], hosts: {}, recap: {}, stats: { totalTasks: 0, totalHosts: 0, successRate: 0, changedRate: 0, }, }; const lines = output.split("\n"); let currentPlay = null; let currentTask = null; let inRecap = false; // Patterns de détection const patterns = { config: /^Using\s+(.+)\s+as config file$/, play: /^PLAY\s+\[([^\]]+)\]\s*\*+$/, task: /^TASK\s+\[([^\]]+)\]\s*\*+$/, hostResult: /^(ok|changed|failed|unreachable|skipping|fatal):\s*\[([^\]]+)\]\s*(?:=>\s*)?(.*)$/i, hostResultAlt: /^([\w\.\-]+)\s*\|\s*(SUCCESS|CHANGED|FAILED|UNREACHABLE)\s*(?:\|\s*rc=(\d+))?\s*>>?\s*$/i, recap: /^PLAY RECAP\s*\*+$/, recapLine: /^([\w\.\-]+)\s*:\s*ok=(\d+)\s+changed=(\d+)\s+unreachable=(\d+)\s+failed=(\d+)(?:\s+skipped=(\d+))?(?:\s+rescued=(\d+))?(?:\s+ignored=(\d+))?/, }; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; // Détecter le fichier de configuration let match = line.match(patterns.config); if (match) { result.metadata.configFile = match[1]; continue; } // Détecter un PLAY match = line.match(patterns.play); if (match) { currentPlay = { name: match[1], tasks: [], startIndex: i, }; result.plays.push(currentPlay); if (!result.metadata.playbookName) { result.metadata.playbookName = match[1]; } continue; } // Détecter une TASK match = line.match(patterns.task); if (match) { currentTask = { name: match[1], hostResults: [], startIndex: i, }; if (currentPlay) { currentPlay.tasks.push(currentTask); } result.stats.totalTasks++; continue; } // Détecter PLAY RECAP if (patterns.recap.test(line)) { inRecap = true; continue; } // Parser les lignes de RECAP if (inRecap) { match = line.match(patterns.recapLine); if (match) { const hostname = match[1]; result.recap[hostname] = { ok: parseInt(match[2]) || 0, changed: parseInt(match[3]) || 0, unreachable: parseInt(match[4]) || 0, failed: parseInt(match[5]) || 0, skipped: parseInt(match[6]) || 0, rescued: parseInt(match[7]) || 0, ignored: parseInt(match[8]) || 0, }; // Déterminer le statut global de l'hôte const stats = result.recap[hostname]; if (stats.failed > 0 || stats.unreachable > 0) { result.hosts[hostname] = { ...result.hosts[hostname], globalStatus: "failed" }; } else if (stats.changed > 0) { result.hosts[hostname] = { ...result.hosts[hostname], globalStatus: "changed" }; } else { result.hosts[hostname] = { ...result.hosts[hostname], globalStatus: "ok" }; } } continue; } // Détecter les résultats par hôte (format standard) match = line.match(patterns.hostResult); if (match && currentTask) { const status = match[1].toLowerCase(); const hostname = match[2]; let outputData = match[3] || ""; // Collecter les lignes suivantes si c'est un JSON multi-lignes if (outputData.includes("{") && !outputData.includes("}")) { let braceCount = (outputData.match(/{/g) || []).length - (outputData.match(/}/g) || []).length; while (braceCount > 0 && i + 1 < lines.length) { i++; outputData += "\n" + lines[i]; braceCount += (lines[i].match(/{/g) || []).length - (lines[i].match(/}/g) || []).length; } } const hostResult = { hostname, status, output: outputData, taskName: currentTask.name, }; // Parser le JSON si présent try { const jsonMatch = outputData.match(/=>\s*({[\s\S]*})/m) || outputData.match(/^({[\s\S]*})$/m); if (jsonMatch) { hostResult.parsedOutput = JSON.parse(jsonMatch[1]); } } catch (e) { // Ignorer les erreurs de parsing JSON } currentTask.hostResults.push(hostResult); // Enregistrer l'hôte s'il n'existe pas if (!result.hosts[hostname]) { result.hosts[hostname] = { taskResults: [], globalStatus: "unknown" }; } result.hosts[hostname].taskResults.push(hostResult); continue; } // Format alternatif (hostname | STATUS | rc=X >>) match = line.match(patterns.hostResultAlt); if (match && currentTask) { const hostname = match[1]; const status = match[2].toLowerCase(); const rc = match[3] ? parseInt(match[3]) : 0; // Collecter la sortie sur les lignes suivantes let outputLines = []; while (i + 1 < lines.length) { const nextLine = lines[i + 1]; if (nextLine.match(patterns.hostResultAlt) || nextLine.match(patterns.task) || nextLine.match(patterns.play) || nextLine.match(patterns.recap)) { break; } i++; outputLines.push(lines[i]); } const hostResult = { hostname, status, returnCode: rc, output: outputLines.join("\n"), taskName: currentTask.name, }; currentTask.hostResults.push(hostResult); if (!result.hosts[hostname]) { result.hosts[hostname] = { taskResults: [], globalStatus: "unknown" }; } result.hosts[hostname].taskResults.push(hostResult); } } // Calculer les statistiques result.stats.totalHosts = Object.keys(result.hosts).length; if (Object.keys(result.recap).length > 0) { let totalOk = 0, totalChanged = 0, totalFailed = 0, totalTasks = 0; for (const stats of Object.values(result.recap)) { totalOk += stats.ok; totalChanged += stats.changed; totalFailed += stats.failed + stats.unreachable; totalTasks += stats.ok + stats.changed + stats.failed + stats.unreachable + stats.skipped; } result.stats.successRate = totalTasks > 0 ? Math.round(((totalOk + totalChanged) / totalTasks) * 100) : 0; result.stats.changedRate = totalTasks > 0 ? Math.round((totalChanged / totalTasks) * 100) : 0; } return result; } detectPlaybookType(parsedOutput) { /** * Détecte automatiquement le type de playbook basé sur le contenu */ const taskNames = parsedOutput.plays.flatMap((p) => p.tasks.map((t) => t.name.toLowerCase())); const playName = (parsedOutput.metadata.playbookName || "").toLowerCase(); // Patterns de détection const patterns = { healthCheck: ["health", "check", "ping", "uptime", "status", "monitor", "disk", "memory", "cpu"], deployment: ["deploy", "release", "version", "rollout", "install", "upgrade"], configuration: ["config", "configure", "setup", "settings", "template"], backup: ["backup", "restore", "snapshot", "archive"], security: ["security", "firewall", "ssl", "certificate", "password", "key"], maintenance: ["clean", "prune", "update", "patch", "restart", "reboot"], }; for (const [type, keywords] of Object.entries(patterns)) { const matchScore = keywords.filter((kw) => playName.includes(kw) || taskNames.some((t) => t.includes(kw))).length; if (matchScore >= 2 || playName.includes(type.toLowerCase())) { return type; } } return "general"; } renderStructuredPlaybookView(parsedOutput, metadata = {}) { /** * Génère le HTML structuré pour l'affichage du playbook */ const playbookType = this.detectPlaybookType(parsedOutput); const isSuccess = !Object.values(parsedOutput.recap).some((r) => r.failed > 0 || r.unreachable > 0); const hasChanges = Object.values(parsedOutput.recap).some((r) => r.changed > 0); // Icônes par type de playbook const typeIcons = { healthCheck: "fa-heartbeat", deployment: "fa-rocket", configuration: "fa-cogs", backup: "fa-database", security: "fa-shield-alt", maintenance: "fa-tools", general: "fa-play-circle", }; const typeLabels = { healthCheck: "Health Check", deployment: "Déploiement", configuration: "Configuration", backup: "Backup", security: "Sécurité", maintenance: "Maintenance", general: "Playbook", }; // Générer les cartes d'hôtes const hostCardsHtml = this.renderHostStatusCards(parsedOutput); // Générer l'arborescence des tâches const taskTreeHtml = this.renderTaskHierarchy(parsedOutput); // Générer les statistiques const statsHtml = this.renderExecutionStats(parsedOutput); return `
${typeLabels[playbookType]} ${hasChanges ? 'Changes Applied' : ""}

${this.escapeHtml(parsedOutput.metadata.playbookName || "Ansible Playbook")}

${parsedOutput.stats.totalHosts} hôte(s) ${parsedOutput.stats.totalTasks} tâche(s) ${metadata.duration || "N/A"}

${isSuccess ? "✓" : "✗"} ${isSuccess ? "SUCCESS" : "FAILED"}
${metadata.date || ""}
${statsHtml}

État des Hôtes

${hostCardsHtml}

Hiérarchie des Tâches

${taskTreeHtml}
Afficher la sortie brute

                
`; } openAnsibleRawOutputModal() { const rawOutput = this.currentTaskLogRawOutput || this.currentAdHocRawOutput || ""; const content = `
Sortie brute

            
`; this.showModal(this.currentStructuredPlaybookTitle || "Sortie brute", content); setTimeout(() => { const el = document.getElementById("ansible-raw-output-modal"); if (el) { el.innerHTML = this.formatAnsibleOutput(rawOutput, true); } }, 50); } renderHostStatusCards(parsedOutput) { const hosts = Object.entries(parsedOutput.recap); if (hosts.length === 0) { return '
Aucun hôte détecté
'; } return hosts .map(([hostname, stats]) => { const total = stats.ok + stats.changed + stats.failed + stats.unreachable + stats.skipped; const successPercent = total > 0 ? Math.round(((stats.ok + stats.changed) / total) * 100) : 0; const isFailed = stats.failed > 0 || stats.unreachable > 0; const hasChanges = stats.changed > 0; let statusClass, statusIcon, statusBg; if (isFailed) { statusClass = "border-red-500/50 bg-red-900/20"; statusIcon = ''; statusBg = "bg-red-500"; } else if (hasChanges) { statusClass = "border-yellow-500/50 bg-yellow-900/20"; statusIcon = ''; statusBg = "bg-yellow-500"; } else { statusClass = "border-green-500/50 bg-green-900/20"; statusIcon = ''; statusBg = "bg-green-500"; } const hostStatus = isFailed ? "failed" : hasChanges ? "changed" : "ok"; return `
${statusIcon} ${this.escapeHtml(hostname)}
${successPercent}%
${stats.ok > 0 ? `
` : ""} ${stats.changed > 0 ? `
` : ""} ${stats.skipped > 0 ? `
` : ""} ${stats.failed > 0 ? `
` : ""} ${stats.unreachable > 0 ? `
` : ""}
${stats.ok} ok ${stats.changed} chg ${stats.failed} fail
`; }) .join(""); } renderTaskHierarchy(parsedOutput) { if (parsedOutput.plays.length === 0) { return '
Aucune tâche détectée
'; } return parsedOutput.plays .map((play, playIndex) => { const playTasks = play.tasks; const allTasksSuccess = playTasks.every((t) => t.hostResults.every((r) => r.status === "ok" || r.status === "changed" || r.status === "skipping")); const hasFailedTasks = playTasks.some((t) => t.hostResults.some((r) => r.status === "failed" || r.status === "fatal" || r.status === "unreachable")); const playStatusIcon = hasFailedTasks ? '' : ''; const tasksHtml = playTasks .map((task, taskIndex) => { const hasFailures = task.hostResults.some((r) => r.status === "failed" || r.status === "fatal" || r.status === "unreachable"); const hasChanges = task.hostResults.some((r) => r.status === "changed"); const allSkipped = task.hostResults.every((r) => r.status === "skipping" || r.status === "skipped"); let taskIcon, taskColor; if (hasFailures) { taskIcon = "fa-times-circle"; taskColor = "text-red-400"; } else if (hasChanges) { taskIcon = "fa-exchange-alt"; taskColor = "text-yellow-400"; } else if (allSkipped) { taskIcon = "fa-forward"; taskColor = "text-gray-500"; } else { taskIcon = "fa-check-circle"; taskColor = "text-green-400"; } const hostResultsHtml = task.hostResults .map((result) => { let resultIcon, resultColor, resultBg; switch (result.status) { case "ok": resultIcon = "fa-check"; resultColor = "text-green-400"; resultBg = "bg-green-900/30"; break; case "changed": resultIcon = "fa-exchange-alt"; resultColor = "text-yellow-400"; resultBg = "bg-yellow-900/30"; break; case "failed": case "fatal": resultIcon = "fa-times"; resultColor = "text-red-400"; resultBg = "bg-red-900/30"; break; case "unreachable": resultIcon = "fa-unlink"; resultColor = "text-orange-400"; resultBg = "bg-orange-900/30"; break; case "skipping": case "skipped": resultIcon = "fa-forward"; resultColor = "text-gray-500"; resultBg = "bg-gray-800/50"; break; default: resultIcon = "fa-question"; resultColor = "text-gray-400"; resultBg = "bg-gray-800/50"; } // Extraire les données importantes de l'output const toPreviewString = (value) => { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value); try { return JSON.stringify(value); } catch (e) { return String(value); } }; let outputPreview = ""; if (result.parsedOutput) { const po = result.parsedOutput; if (po.msg !== undefined) { outputPreview = toPreviewString(po.msg); } else if (po.stdout !== undefined) { const stdoutStr = toPreviewString(po.stdout); outputPreview = stdoutStr.substring(0, 100); } else if (po.cmd !== undefined) { outputPreview = toPreviewString(Array.isArray(po.cmd) ? po.cmd.join(" ") : po.cmd); } } return `
${this.escapeHtml(result.hostname)}
${outputPreview ? `${this.escapeHtml(outputPreview.substring(0, 50))}${outputPreview.length > 50 ? "..." : ""}` : ""} ${result.status}
`; }) .join(""); return `
${this.escapeHtml(task.name)}
${task.hostResults.length} hôte(s)
${task.hostResults .slice(0, 5) .map((r) => { const dotColor = r.status === "ok" ? "bg-green-500" : r.status === "changed" ? "bg-yellow-500" : r.status === "failed" || r.status === "fatal" ? "bg-red-500" : "bg-gray-500"; return `
`; }) .join("")} ${task.hostResults.length > 5 ? `+${task.hostResults.length - 5}` : ""}
${hostResultsHtml}
`; }) .join(""); return `
${playStatusIcon} PLAY [${this.escapeHtml(play.name)}] ${playTasks.length} tâche(s)
${tasksHtml}
`; }) .join(""); } renderExecutionStats(parsedOutput) { const recap = parsedOutput.recap; const hosts = Object.keys(recap); if (hosts.length === 0) return ""; let totalOk = 0, totalChanged = 0, totalFailed = 0, totalSkipped = 0, totalUnreachable = 0; for (const stats of Object.values(recap)) { totalOk += stats.ok; totalChanged += stats.changed; totalFailed += stats.failed; totalSkipped += stats.skipped; totalUnreachable += stats.unreachable; } const total = totalOk + totalChanged + totalFailed + totalSkipped + totalUnreachable; const successRate = total > 0 ? Math.round(((totalOk + totalChanged) / total) * 100) : 0; return `
OK
${totalOk}
Changed
${totalChanged}
Failed
${totalFailed}
Success Rate
${successRate}%
`; } // Méthodes d'interaction pour la vue structurée filterAnsibleViewByStatus(status) { document.querySelectorAll(".av-filter-btn").forEach((btn) => { btn.classList.remove("active", "bg-gray-700", "text-gray-300"); btn.classList.add("bg-gray-700/50", "text-gray-400"); }); document.querySelector(`.av-filter-btn[data-filter="${status}"]`)?.classList.add("active", "bg-gray-700", "text-gray-300"); document.querySelector(`.av-filter-btn[data-filter="${status}"]`)?.classList.remove("bg-gray-700/50", "text-gray-400"); document.querySelectorAll(".host-card-item").forEach((card) => { if (status === "all" || card.dataset.status === status) { card.style.display = ""; } else { card.style.display = "none"; } }); } expandAllTasks() { document.querySelectorAll(".task-item").forEach((item) => { item.setAttribute("open", "open"); }); } collapseAllTasks() { document.querySelectorAll(".task-item").forEach((item) => { item.removeAttribute("open"); }); } showHostDetails(hostname) { if (!this.currentParsedOutput || !this.currentParsedOutput.hosts[hostname]) return; const hostData = this.currentParsedOutput.hosts[hostname]; const recapData = this.currentParsedOutput.recap[hostname] || {}; const tasksHtml = hostData.taskResults .map((result) => { let statusIcon, statusColor; switch (result.status) { case "ok": statusIcon = "fa-check"; statusColor = "text-green-400"; break; case "changed": statusIcon = "fa-exchange-alt"; statusColor = "text-yellow-400"; break; case "failed": case "fatal": statusIcon = "fa-times"; statusColor = "text-red-400"; break; default: statusIcon = "fa-minus"; statusColor = "text-gray-400"; } return `
${this.escapeHtml(result.taskName)}
${result.status.toUpperCase()}
${result.output ? `
${this.escapeHtml(result.output)}
` : "" }
`; }) .join(""); const content = `

${this.escapeHtml(hostname)}

${recapData.ok || 0} ok${recapData.changed || 0} changed${recapData.failed || 0} failed
${tasksHtml}
`; this.showModal(`Détails: ${hostname}`, content); } switchTaskLogHostTab(index) { if (!this.currentTaskLogHostOutputs || !this.currentTaskLogHostOutputs[index]) return; const hostOutput = this.currentTaskLogHostOutputs[index]; const outputPre = document.getElementById("tasklog-output"); const tabs = document.querySelectorAll(".tasklog-host-tab"); // Mettre à jour l'onglet actif tabs.forEach((tab, i) => { if (i === index) { const host = this.currentTaskLogHostOutputs[i]; const statusColor = host.status === "changed" || host.status === "success" ? "bg-green-600/80 border-green-500" : host.status === "failed" || host.status === "unreachable" ? "bg-red-600/80 border-red-500" : "bg-gray-600/80 border-gray-500"; tab.className = `tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border ${statusColor} text-white`; } else { tab.className = "tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border bg-gray-800 text-gray-400 hover:text-white border-gray-700"; } }); // Afficher le contenu if (outputPre) { outputPre.innerHTML = this.formatAnsibleOutput(hostOutput.output, hostOutput.status === "changed" || hostOutput.status === "success"); } } showAllTaskLogHostsOutput() { if (!this.currentTaskLogHostOutputs) return; const outputPre = document.getElementById("tasklog-output"); if (outputPre) { const allOutput = this.currentTaskLogHostOutputs.map((h) => h.output).join("\n\n"); outputPre.innerHTML = this.formatAnsibleOutput(allOutput, true); } // Désélectionner tous les onglets document.querySelectorAll(".tasklog-host-tab").forEach((tab) => { tab.className = "tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border bg-gray-800 text-gray-400 hover:text-white border-gray-700"; }); } copyTaskLogOutput() { const text = this.currentTaskLogRawOutput || ""; this.copyTextToClipboard(text) .then(() => { this.showNotification("Sortie copiée dans le presse-papiers", "success"); }) .catch(() => { this.showNotification("Erreur lors de la copie", "error"); }); } downloadTaskLog(path) { // Créer un lien pour télécharger le fichier this.showNotification("Fonctionnalité de téléchargement à implémenter", "info"); } async deleteTaskLog(logId, filename) { if (!confirm(`Supprimer le log "${filename}" ? Cette action est définitive.`)) { return; } try { await this.apiCall(`/api/tasks/logs/${logId}`, { method: "DELETE", }); this.showNotification("Log supprimé", "success"); this.taskLogs = (this.taskLogs || []).filter((l) => String(l.id) !== String(logId)); this.renderTasks(); this.loadTaskLogsWithFilters(); } catch (error) { this.showNotification(`Erreur: ${error.message}`, "error"); } } // ===== FILTRAGE DES TÂCHES ===== filterTasksByStatus(status) { this.currentStatusFilter = status; this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination // Mettre à jour l'apparence des boutons document.querySelectorAll(".task-filter-btn").forEach((btn) => { btn.classList.remove("active", "bg-purple-600", "bg-blue-600", "bg-green-600", "bg-red-600"); btn.classList.add("bg-gray-700"); }); const activeBtn = document.querySelector(`.task-filter-btn[data-status="${status}"]`); if (activeBtn) { activeBtn.classList.remove("bg-gray-700"); const colorMap = { all: "bg-purple-600", running: "bg-blue-600", completed: "bg-green-600", failed: "bg-red-600", }; activeBtn.classList.add("active", colorMap[status] || "bg-purple-600"); } // Recharger les logs avec le filtre this.loadTaskLogsWithFilters(); } filterTasksByCategory(category) { this.currentCategoryFilter = category; this.currentSubcategoryFilter = "all"; // Reset subcategory when category changes this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination this.loadTaskLogsWithFilters(); } filterTasksBySubcategory(subcategory) { this.currentSubcategoryFilter = subcategory; this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination this.loadTaskLogsWithFilters(); } filterTasksByTarget(target) { this.currentTargetFilter = target; this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination this.loadTaskLogsWithFilters(); } // Fonctions pour effacer les filtres individuellement clearTargetFilter() { this.currentTargetFilter = "all"; this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); this.showNotification("Filtre cible effacé", "info"); } clearCategoryFilter() { this.currentCategoryFilter = "all"; this.currentSubcategoryFilter = "all"; this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); this.showNotification("Filtre catégorie effacé", "info"); } clearAllTaskFilters() { this.currentTargetFilter = "all"; this.currentCategoryFilter = "all"; this.currentSubcategoryFilter = "all"; this.currentStatusFilter = "all"; this.currentSourceTypeFilter = "all"; this.currentHourStart = ""; this.currentHourEnd = ""; this.tasksDisplayedCount = this.tasksPerPage; // Réinitialiser les inputs d'heure const hourStartInput = document.getElementById("task-cal-hour-start"); const hourEndInput = document.getElementById("task-cal-hour-end"); if (hourStartInput) hourStartInput.value = ""; if (hourEndInput) hourEndInput.value = ""; this.loadTaskLogsWithFilters(); this.showNotification("Tous les filtres effacés", "info"); } filterTasksBySourceType(sourceType) { this.currentSourceTypeFilter = sourceType; this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); } clearSourceTypeFilter() { this.currentSourceTypeFilter = "all"; this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); this.showNotification("Filtre type effacé", "info"); } clearHourFilter() { this.currentHourStart = ""; this.currentHourEnd = ""; // Réinitialiser les inputs d'heure const hourStartInput = document.getElementById("task-cal-hour-start"); const hourEndInput = document.getElementById("task-cal-hour-end"); if (hourStartInput) hourStartInput.value = ""; if (hourEndInput) hourEndInput.value = ""; this.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); this.showNotification("Filtre horaire effacé", "info"); } async loadTaskLogsWithFilters() { // Afficher un indicateur de chargement inline (pas le loader global) const container = document.getElementById("tasks-list"); const logsSection = document.getElementById("task-logs-section"); if (logsSection) { logsSection.innerHTML = `

Chargement...

`; } else if (container) { // Garder le header mais montrer le chargement dans la liste const existingHeader = container.querySelector(".flex.flex-col.gap-2.mb-4"); if (!existingHeader) { container.innerHTML = `
Chargement...
`; } } const params = new URLSearchParams(); if (this.currentStatusFilter && this.currentStatusFilter !== "all") { params.append("status", this.currentStatusFilter); } // Si plusieurs dates sont sélectionnées, utiliser le premier jour comme filtre principal (compat API) if (this.selectedTaskDates && this.selectedTaskDates.length > 0) { const firstDate = this.parseDateKey(this.selectedTaskDates[0]); const year = String(firstDate.getFullYear()); const month = String(firstDate.getMonth() + 1).padStart(2, "0"); const day = String(firstDate.getDate()).padStart(2, "0"); params.append("year", year); params.append("month", month); params.append("day", day); } else { if (this.currentDateFilter.year) params.append("year", this.currentDateFilter.year); if (this.currentDateFilter.month) params.append("month", this.currentDateFilter.month); if (this.currentDateFilter.day) params.append("day", this.currentDateFilter.day); } // Filtres d'heure if (this.currentHourStart) { params.append("hour_start", this.currentHourStart); } if (this.currentHourEnd) { params.append("hour_end", this.currentHourEnd); } if (this.currentTargetFilter && this.currentTargetFilter !== "all") { params.append("target", this.currentTargetFilter); } if (this.currentCategoryFilter && this.currentCategoryFilter !== "all") { params.append("category", this.currentCategoryFilter); } // Filtre par type de source if (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== "all") { params.append("source_type", this.currentSourceTypeFilter); } // Pagination côté serveur params.append("limit", this.tasksPerPage); params.append("offset", 0); // Toujours commencer à 0 lors d'un nouveau filtre try { const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`); this.taskLogs = result.logs || []; this.tasksTotalCount = result.total_count || 0; this.tasksHasMore = result.has_more || false; this.tasksDisplayedCount = this.taskLogs.length; this.renderTasks(); this.updateTaskCounts(); } catch (error) { console.error("Erreur chargement logs:", error); } } updateTaskCounts() { // Mettre à jour les compteurs dans les boutons const stats = this.taskLogsStats || { total: 0, completed: 0, failed: 0, running: 0, pending: 0 }; const running = this.tasks.filter((t) => t.status === "running").length; const countAll = document.getElementById("count-all"); const countRunning = document.getElementById("count-running"); const countCompleted = document.getElementById("count-completed"); const countFailed = document.getElementById("count-failed"); if (countAll) countAll.textContent = (stats.total || 0) + running; if (countRunning) countRunning.textContent = running + (stats.running || 0); if (countCompleted) countCompleted.textContent = stats.completed || 0; if (countFailed) countFailed.textContent = stats.failed || 0; // Mettre à jour le badge principal const badge = document.getElementById("tasks-count-badge"); if (badge) badge.textContent = (stats.total || 0) + running; console.log("Task counts updated:", { stats, running }); } updateDateFilters() { // Mettre à jour le libellé du bouton et le résumé sous le calendrier const label = document.getElementById("task-date-filter-label"); const summary = document.getElementById("task-cal-summary"); let text = "Toutes les dates"; if (this.selectedTaskDates && this.selectedTaskDates.length > 0) { if (this.selectedTaskDates.length === 1) { const d = this.parseDateKey(this.selectedTaskDates[0]); text = d.toLocaleDateString("fr-FR", { year: "numeric", month: "short", day: "2-digit" }); } else { text = `${this.selectedTaskDates.length} jours sélectionnés`; } } if (label) label.textContent = text; if (summary) summary.textContent = text; } applyDateFilter() { // Lorsque plusieurs dates sont sélectionnées, on garde uniquement la première pour l'API if (this.selectedTaskDates && this.selectedTaskDates.length > 0) { const firstDate = this.parseDateKey(this.selectedTaskDates[0]); this.currentDateFilter.year = String(firstDate.getFullYear()); this.currentDateFilter.month = String(firstDate.getMonth() + 1).padStart(2, "0"); this.currentDateFilter.day = String(firstDate.getDate()).padStart(2, "0"); } else { this.currentDateFilter = { year: "", month: "", day: "" }; } // Récupérer les heures depuis les inputs const hourStartInput = document.getElementById("task-cal-hour-start"); const hourEndInput = document.getElementById("task-cal-hour-end"); this.currentHourStart = hourStartInput ? hourStartInput.value : ""; this.currentHourEnd = hourEndInput ? hourEndInput.value : ""; this.updateDateFilters(); this.loadTaskLogsWithFilters(); } clearDateFilters() { this.currentDateFilter = { year: "", month: "", day: "" }; this.selectedTaskDates = []; this.currentHourStart = ""; this.currentHourEnd = ""; // Réinitialiser les inputs d'heure const hourStartInput = document.getElementById("task-cal-hour-start"); const hourEndInput = document.getElementById("task-cal-hour-end"); if (hourStartInput) hourStartInput.value = ""; if (hourEndInput) hourEndInput.value = ""; this.updateDateFilters(); this.renderTaskCalendar(); this.loadTaskLogsWithFilters(); } async refreshTaskLogs() { // Ne pas utiliser showLoading() pour éviter le message "Exécution de la tâche..." // Afficher un indicateur de chargement inline à la place const container = document.getElementById("tasks-list"); if (container) { container.innerHTML = `
Chargement des logs...
`; } try { const [taskLogsData, taskStatsData, taskDatesData] = await Promise.all([this.apiCall(`/api/tasks/logs?limit=${this.tasksPerPage}&offset=0`), this.apiCall("/api/tasks/logs/stats"), this.apiCall("/api/tasks/logs/dates")]); this.taskLogs = taskLogsData.logs || []; this.tasksTotalCount = taskLogsData.total_count || 0; this.tasksHasMore = taskLogsData.has_more || false; this.taskLogsStats = taskStatsData; this.taskLogsDates = taskDatesData; this.renderTasks(); this.updateDateFilters(); this.updateTaskCounts(); this.showNotification("Logs de tâches rafraîchis", "success"); } catch (error) { this.showNotification(`Erreur: ${error.message}`, "error"); if (container) { container.innerHTML = `

Erreur de chargement

`; } } } createTaskCard(task, isRunning) { const statusBadge = this.getStatusBadge(task.status); const progressBar = isRunning ? `
` : ""; const startTime = task.start_time ? new Date(task.start_time).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--"; const duration = task.duration || "--"; // Icône selon le statut const statusIcon = { completed: '', failed: '', running: '', pending: '', }[task.status] || ''; const taskCard = document.createElement("div"); taskCard.className = `host-card ${isRunning ? "border-l-4 border-blue-500" : ""} ${task.status === "failed" ? "border-l-4 border-red-500" : ""}`; taskCard.innerHTML = `
${statusIcon}

${task.name}

${statusBadge}

Cible: ${task.host}

Début: ${startTime} • Durée: ${duration}

${progressBar} ${task.output ? `
${this.escapeHtml(task.output.substring(0, 150))}${task.output.length > 150 ? "..." : ""}
` : "" } ${task.error ? `
${this.escapeHtml(task.error.substring(0, 150))}${task.error.length > 150 ? "..." : ""}
` : "" }
${task.status === "failed" ? ` ` : "" }
`; return taskCard; } escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } viewTaskDetails(taskId) { const task = this.tasks.find((t) => t.id === taskId); if (!task) { this.showNotification("Tâche non trouvée", "error"); return; } const statusBadge = this.getStatusBadge(task.status); const startTime = task.start_time ? new Date(task.start_time).toLocaleString("fr-FR") : "--"; this.showModal( `Détails de la tâche #${task.id}`, `

${task.name}

${statusBadge}
Cible: ${task.host}
Durée: ${task.duration || "--"}
Début: ${startTime}
Progression: ${task.progress}%
${task.output ? `

Sortie

${this.escapeHtml(task.output)}
` : "" } ${task.error ? `

Erreur

${this.escapeHtml(task.error)}
` : "" }
${task.status === "failed" ? ` ` : "" }
`, ); } copyToClipboard(text) { this.copyTextToClipboard(text) .then(() => { this.showNotification("Copié dans le presse-papiers", "success"); }) .catch(() => { this.showNotification("Erreur lors de la copie", "error"); }); } async copyTextToClipboard(text) { const value = String(text ?? ""); if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { try { await navigator.clipboard.writeText(value); return; } catch (e) { // fall back } } const ta = document.createElement("textarea"); ta.value = value; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.left = "-9999px"; ta.style.top = "0"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand("copy"); document.body.removeChild(ta); if (!ok) { throw new Error("copy_failed"); } } async clearCompletedTasks() { const completedTasks = this.tasks.filter((t) => t.status === "completed" || t.status === "failed"); if (completedTasks.length === 0) { this.showNotification("Aucune tâche à nettoyer", "info"); return; } // Supprimer localement les tâches terminées this.tasks = this.tasks.filter((t) => t.status === "running" || t.status === "pending"); this.renderTasks(); this.showNotification(`${completedTasks.length} tâche(s) nettoyée(s)`, "success"); } async retryTask(taskId) { const task = this.tasks.find((t) => t.id === taskId); if (!task) return; // Extraire l'action du nom de la tâche const actionMap = { "Mise à jour système": "upgrade", "Redémarrage système": "reboot", "Vérification de santé": "health-check", Sauvegarde: "backup", }; const action = actionMap[task.name] || "health-check"; await this.executeTask(action, task.host); } async cancelTask(taskId) { if (!confirm("Êtes-vous sûr de vouloir annuler cette tâche ?")) { return; } try { // Utiliser l'API centralisée avec JWT (Authorization: Bearer ) await this.apiCall(`/api/tasks/${taskId}/cancel`, { method: "POST", }); this.showNotification("Tâche annulée avec succès", "success"); // Mettre à jour la liste des tâches const task = this.tasks.find((t) => String(t.id) === String(taskId)); if (task) { task.status = "cancelled"; task.error = "Tâche annulée par l'utilisateur"; } // Rafraîchir l'affichage this.pollRunningTasks(); this.renderTasks(); } catch (error) { console.error("Erreur annulation tâche:", error); this.showNotification(error.message || "Erreur lors de l'annulation de la tâche", "error"); } } showAdHocConsole() { console.log("Opening Ad-Hoc Console with:", { adhocHistory: this.adhocHistory, adhocCategories: this.adhocCategories, }); const hostOptions = this.hosts.map((h) => ``).join(""); const groupOptions = this.ansibleGroups.map((g) => ``).join(""); // Catégories par défaut si aucune n'est chargée const categories = this.adhocCategories.length > 0 ? this.adhocCategories : [ { name: "default", description: "Commandes générales", color: "#7c3aed", icon: "fa-terminal" }, { name: "diagnostic", description: "Diagnostic", color: "#10b981", icon: "fa-stethoscope" }, { name: "maintenance", description: "Maintenance", color: "#f59e0b", icon: "fa-wrench" }, { name: "deployment", description: "Déploiement", color: "#3b82f6", icon: "fa-rocket" }, ]; const categoryOptions = categories.map((c) => ``).join(""); // Stocker le filtre actuel (utiliser la propriété de classe) this.currentHistoryCategoryFilter = this.currentHistoryCategoryFilter || "all"; // Générer la liste de l'historique groupé par catégorie const historyByCategory = {}; (this.adhocHistory || []).forEach((cmd) => { const cat = cmd.category || "default"; if (!historyByCategory[cat]) historyByCategory[cat] = []; historyByCategory[cat].push(cmd); }); let historyHtml = ""; if (Object.keys(historyByCategory).length > 0) { Object.entries(historyByCategory).forEach(([category, commands]) => { // Filtrer par catégorie si un filtre est actif if (this.currentHistoryCategoryFilter !== "all" && category !== this.currentHistoryCategoryFilter) { return; // Skip cette catégorie } const catInfo = categories.find((c) => c.name === category) || { color: "#7c3aed", icon: "fa-folder" }; historyHtml += `
${category.toUpperCase()} (${commands.length})
${commands .map( (cmd) => `
${this.escapeHtml(cmd.command)} ${cmd.target}
${cmd.use_count || 1}x
`, ) .join("")}
`; }); } // Afficher les catégories disponibles avec filtrage et actions // Ajouter "toutes" comme option de filtrage let categoriesListHtml = ` `; categories.forEach((cat) => { const isDefault = cat.name === "default"; categoriesListHtml += `
${!isDefault ? ` ` : `` }
`; }); this.showModal( "Console Ad-Hoc Ansible", `
Exécutez des commandes shell directement sur vos hôtes via Ansible
Hôtes ciblés
sec

Historique

Catégories:

${categoriesListHtml}
${historyHtml || '

Aucune commande dans l\'historique
Exécutez une commande pour la sauvegarder

'}
`, ); // Initialiser l'aperçu des hôtes ciblés this.updateTargetHostsPreview("all"); // Ajouter l'event listener pour mettre à jour l'aperçu quand la cible change const targetSelect = document.getElementById("adhoc-target"); if (targetSelect) { targetSelect.addEventListener("change", (e) => { this.updateTargetHostsPreview(e.target.value); }); } } /** * Récupère la liste des hôtes pour une cible donnée (groupe, hôte individuel ou "all") */ getHostsForTarget(target) { if (!target || target === "all") { // Tous les hôtes return this.hosts; } // Vérifier si c'est un groupe if (this.ansibleGroups.includes(target)) { return this.hosts.filter((h) => h.groups && h.groups.includes(target)); } // Sinon c'est un hôte individuel const host = this.hosts.find((h) => h.name === target); return host ? [host] : []; } /** * Met à jour l'aperçu des hôtes ciblés dans la console Ad-Hoc */ updateTargetHostsPreview(target) { const listContainer = document.getElementById("adhoc-target-hosts-list"); const countSpan = document.getElementById("adhoc-target-hosts-count"); const previewContainer = document.getElementById("adhoc-target-hosts-preview"); if (!listContainer || !countSpan || !previewContainer) return; const hosts = this.getHostsForTarget(target); // Mettre à jour le compteur countSpan.textContent = `${hosts.length} hôte${hosts.length > 1 ? "s" : ""}`; // Générer les badges des hôtes if (hosts.length === 0) { listContainer.innerHTML = 'Aucun hôte trouvé'; previewContainer.classList.add("border-amber-700/50"); previewContainer.classList.remove("border-gray-700"); } else { previewContainer.classList.remove("border-amber-700/50"); previewContainer.classList.add("border-gray-700"); listContainer.innerHTML = hosts .map((h) => { const statusColor = h.bootstrap_ok ? "bg-green-900/40 text-green-400 border-green-700/50" : "bg-gray-700/50 text-gray-400 border-gray-600/50"; const statusIcon = h.bootstrap_ok ? "fa-check-circle" : "fa-circle"; return ` ${this.escapeHtml(h.name)} ${h.ip ? `${h.ip}` : ""} `; }) .join(""); } } loadHistoryCommand(command, target, module, become) { document.getElementById("adhoc-command").value = command; document.getElementById("adhoc-target").value = target; document.getElementById("adhoc-module").value = module; document.getElementById("adhoc-become").checked = become; // Mettre à jour l'aperçu des hôtes ciblés this.updateTargetHostsPreview(target); this.showNotification("Commande chargée depuis l'historique", "info"); } /** * Rafraîchit dynamiquement la section historique des commandes Ad-Hoc * sans recharger toute la modale */ async refreshAdHocHistory() { try { // Récupérer l'historique mis à jour depuis l'API const historyData = await this.apiCall("/api/adhoc/history"); this.adhocHistory = historyData.commands || []; const historyContainer = document.getElementById("adhoc-history-container"); if (!historyContainer) return; // Catégories pour le rendu const categories = this.adhocCategories.length > 0 ? this.adhocCategories : [ { name: "default", description: "Commandes générales", color: "#7c3aed", icon: "fa-terminal" }, { name: "diagnostic", description: "Diagnostic", color: "#10b981", icon: "fa-stethoscope" }, { name: "maintenance", description: "Maintenance", color: "#f59e0b", icon: "fa-wrench" }, { name: "deployment", description: "Déploiement", color: "#3b82f6", icon: "fa-rocket" }, ]; // Générer la liste de l'historique groupé par catégorie const historyByCategory = {}; (this.adhocHistory || []).forEach((cmd) => { const cat = cmd.category || "default"; if (!historyByCategory[cat]) historyByCategory[cat] = []; historyByCategory[cat].push(cmd); }); let historyHtml = ""; if (Object.keys(historyByCategory).length > 0) { Object.entries(historyByCategory).forEach(([category, commands]) => { // Filtrer par catégorie si un filtre est actif if (this.currentHistoryCategoryFilter !== "all" && category !== this.currentHistoryCategoryFilter) { return; } const catInfo = categories.find((c) => c.name === category) || { color: "#7c3aed", icon: "fa-folder" }; historyHtml += `
${category.toUpperCase()} (${commands.length})
${commands .map( (cmd) => `
${this.escapeHtml(cmd.command)} ${cmd.target}
${cmd.use_count || 1}x
`, ) .join("")}
`; }); } // Mettre à jour le contenu avec animation historyContainer.style.opacity = "0.5"; historyContainer.innerHTML = historyHtml || '

Aucune commande dans l\'historique
Exécutez une commande pour la sauvegarder

'; // Animation de mise à jour setTimeout(() => { historyContainer.style.opacity = "1"; historyContainer.style.transition = "opacity 0.3s ease"; }, 50); // Mettre à jour aussi le widget sur le dashboard this.renderAdhocWidget(); } catch (error) { console.error("Erreur lors du rafraîchissement de l'historique:", error); } } async deleteHistoryCommand(commandId) { if (!confirm("Supprimer cette commande de l'historique ?")) return; try { await this.apiCall(`/api/adhoc/history/${commandId}`, { method: "DELETE" }); this.showNotification("Commande supprimée", "success"); // Recharger l'historique et réafficher la console const historyData = await this.apiCall("/api/adhoc/history"); this.adhocHistory = historyData.commands || []; this.showAdHocConsole(); } catch (error) { this.showNotification(`Erreur: ${error.message}`, "error"); } } async editHistoryCommand(commandId) { const categoryOptions = this.adhocCategories.map((c) => ``).join(""); this.showModal( "Modifier la catégorie", `
`, ); } async updateCommandCategory(event, commandId) { event.preventDefault(); const formData = new FormData(event.target); try { await this.apiCall(`/api/adhoc/history/${commandId}/category?category=${encodeURIComponent(formData.get("category"))}&description=${encodeURIComponent(formData.get("description") || "")}`, { method: "PUT", }); this.showNotification("Catégorie mise à jour", "success"); const historyData = await this.apiCall("/api/adhoc/history"); this.adhocHistory = historyData.commands || []; this.showAdHocConsole(); } catch (error) { this.showNotification(`Erreur: ${error.message}`, "error"); } } showAddCategoryModal() { this.showModal( "Ajouter une catégorie", `
`, ); } async createCategory(event) { event.preventDefault(); const formData = new FormData(event.target); try { await this.apiCall(`/api/adhoc/categories?name=${encodeURIComponent(formData.get("name"))}&description=${encodeURIComponent(formData.get("description") || "")}&color=${encodeURIComponent(formData.get("color"))}&icon=${encodeURIComponent(formData.get("icon"))}`, { method: "POST", }); this.showNotification("Catégorie créée", "success"); const categoriesData = await this.apiCall("/api/adhoc/categories"); this.adhocCategories = categoriesData.categories || []; this.showAdHocConsole(); } catch (error) { this.showNotification(`Erreur: ${error.message}`, "error"); } } filterHistoryByCategory(category) { this.currentHistoryCategoryFilter = category; // Mettre à jour les boutons de filtre visuellement document.querySelectorAll(".category-filter-btn").forEach((btn) => { const btnCategory = btn.getAttribute("data-category-filter"); if (btnCategory === category) { if (category === "all") { btn.className = "category-filter-btn active inline-flex items-center px-2 py-1 rounded text-xs transition-all bg-purple-600 text-white hover:bg-purple-500"; } else { btn.classList.add("active", "ring-2", "ring-white/50"); } } else { btn.classList.remove("active", "ring-2", "ring-white/50"); if (btnCategory === "all") { btn.className = "category-filter-btn inline-flex items-center px-2 py-1 rounded text-xs transition-all bg-gray-700 text-gray-400 hover:bg-gray-600"; } } }); // Filtrer les sections de l'historique document.querySelectorAll(".history-category-section").forEach((section) => { const sectionCategory = section.getAttribute("data-category"); if (category === "all" || sectionCategory === category) { section.classList.remove("hidden"); } else { section.classList.add("hidden"); } }); // Si aucun résultat visible, afficher un message const visibleSections = document.querySelectorAll(".history-category-section:not(.hidden)"); const historyContainer = document.querySelector('.overflow-y-auto[style*="max-height: 400px"]'); if (historyContainer && visibleSections.length === 0) { // Pas de commandes dans cette catégorie const emptyMsg = historyContainer.querySelector(".empty-filter-msg"); if (!emptyMsg) { const msg = document.createElement("p"); msg.className = "empty-filter-msg text-xs text-gray-500 text-center py-4"; msg.innerHTML = 'Aucune commande dans cette catégorie'; historyContainer.appendChild(msg); } } else { const emptyMsg = historyContainer?.querySelector(".empty-filter-msg"); if (emptyMsg) emptyMsg.remove(); } } editCategory(categoryName) { const category = this.adhocCategories.find((c) => c.name === categoryName); if (!category) { this.showNotification("Catégorie non trouvée", "error"); return; } this.showModal( `Modifier la catégorie: ${categoryName}`, `
${categoryName === "default" ? '

La catégorie par défaut ne peut pas être renommée

' : ""}

Aperçu:

${category.name}
`, ); } async updateCategory(event, originalName) { event.preventDefault(); const formData = new FormData(event.target); const newName = formData.get("name") || originalName; try { await this.apiCall(`/api/adhoc/categories/${encodeURIComponent(originalName)}`, { method: "PUT", body: JSON.stringify({ name: newName, description: formData.get("description") || "", color: formData.get("color"), icon: formData.get("icon"), }), }); this.showNotification("Catégorie mise à jour", "success"); const categoriesData = await this.apiCall("/api/adhoc/categories"); this.adhocCategories = categoriesData.categories || []; this.showAdHocConsole(); } catch (error) { this.showNotification(`Erreur: ${error.message}`, "error"); } } async deleteCategory(categoryName) { if (categoryName === "default") { this.showNotification("La catégorie par défaut ne peut pas être supprimée", "error"); return; } // Compter les commandes dans cette catégorie const commandsInCategory = (this.adhocHistory || []).filter((cmd) => cmd.category === categoryName).length; const confirmMsg = commandsInCategory > 0 ? `Supprimer la catégorie "${categoryName}" ?\n\n${commandsInCategory} commande(s) seront déplacées vers "default".` : `Supprimer la catégorie "${categoryName}" ?`; if (!confirm(confirmMsg)) return; try { await this.apiCall(`/api/adhoc/categories/${encodeURIComponent(categoryName)}`, { method: "DELETE", }); this.showNotification("Catégorie supprimée", "success"); // Recharger les données const [categoriesData, historyData] = await Promise.all([this.apiCall("/api/adhoc/categories"), this.apiCall("/api/adhoc/history")]); this.adhocCategories = categoriesData.categories || []; this.adhocHistory = historyData.commands || []; // Réinitialiser le filtre si on filtrait sur cette catégorie if (this.currentHistoryCategoryFilter === categoryName) { this.currentHistoryCategoryFilter = "all"; } this.showAdHocConsole(); } catch (error) { this.showNotification(`Erreur: ${error.message}`, "error"); } } async executeAdHocCommand(event) { event.preventDefault(); const formData = new FormData(event.target); const payload = { target: formData.get("target"), command: formData.get("command"), module: formData.get("module"), become: formData.get("become") === "on", timeout: parseInt(formData.get("timeout")) || 60, // catégorie d'historique choisie dans le select category: formData.get("save_category") || "default", }; const resultDiv = document.getElementById("adhoc-result"); const stdoutPre = document.getElementById("adhoc-stdout"); const stderrPre = document.getElementById("adhoc-stderr"); const stderrSection = document.getElementById("adhoc-stderr-section"); const statusIcon = document.getElementById("adhoc-status-icon"); const resultMeta = document.getElementById("adhoc-result-meta"); const resultStats = document.getElementById("adhoc-result-stats"); const resultHeader = document.getElementById("adhoc-result-header"); // Reset et afficher resultDiv.classList.remove("hidden"); stderrSection.classList.add("hidden"); resultHeader.className = "flex items-center justify-between px-4 py-3 bg-gray-800/80 border-b border-gray-700"; statusIcon.innerHTML = ''; statusIcon.className = "w-8 h-8 rounded-lg flex items-center justify-center bg-blue-900/50"; resultMeta.textContent = "Exécution en cours..."; resultStats.innerHTML = ""; stdoutPre.innerHTML = '⏳ Exécution de la commande...'; try { const result = await this.apiCall("/api/ansible/adhoc", { method: "POST", body: JSON.stringify(payload), }); // Mise à jour du header avec le statut if (result.success) { resultHeader.className = "flex items-center justify-between px-4 py-3 bg-green-900/30 border-b border-green-800/50"; statusIcon.innerHTML = ''; statusIcon.className = "w-8 h-8 rounded-lg flex items-center justify-center bg-green-900/50"; resultMeta.innerHTML = `Succès • Cible: ${this.escapeHtml(result.target)}`; } else { resultHeader.className = "flex items-center justify-between px-4 py-3 bg-red-900/30 border-b border-red-800/50"; statusIcon.innerHTML = ''; statusIcon.className = "w-8 h-8 rounded-lg flex items-center justify-center bg-red-900/50"; resultMeta.innerHTML = `Échec • Cible: ${this.escapeHtml(result.target)}`; } // Stats dans le header resultStats.innerHTML = `
${result.duration}s
Code: ${result.return_code}
`; // Parser et afficher le résultat avec onglets par hôte const stdoutContent = result.stdout || "(pas de sortie)"; const hostOutputs = this.parseOutputByHost(stdoutContent); // Si plusieurs hôtes, afficher avec onglets if (hostOutputs.length > 1) { this.renderHostTabs(hostOutputs, result.success); } else { // Un seul hôte ou output non parsable stdoutPre.innerHTML = this.formatAnsibleOutput(stdoutContent, result.success); } // Afficher STDERR si présent if (result.stderr && result.stderr.trim()) { stderrSection.classList.remove("hidden"); stderrPre.innerHTML = this.formatAnsibleWarnings(result.stderr); } this.showNotification(result.success ? "Commande exécutée avec succès" : "Commande échouée", result.success ? "success" : "error"); // Mettre à jour dynamiquement l'historique des commandes await this.refreshAdHocHistory(); } catch (error) { resultHeader.className = "flex items-center justify-between px-4 py-3 bg-red-900/30 border-b border-red-800/50"; statusIcon.innerHTML = ''; statusIcon.className = "w-8 h-8 rounded-lg flex items-center justify-center bg-red-900/50"; resultMeta.innerHTML = 'Erreur de connexion'; resultStats.innerHTML = ""; stdoutPre.innerHTML = `❌ ${this.escapeHtml(error.message)}`; this.showNotification("Erreur lors de l'exécution", "error"); } } formatAnsibleOutput(output, isSuccess) { // Formater la sortie Ansible pour une meilleure lisibilité let formatted = this.escapeHtml(output); // Colorer les hosts avec statut formatted = formatted.replace(/^(\S+)\s*\|\s*(CHANGED|SUCCESS)\s*=>/gm, '$1 $2 =>'); formatted = formatted.replace(/^(\S+)\s*\|\s*(FAILED|UNREACHABLE)\s*(!)?\s*=>/gm, '$1 $2 =>'); // Colorer les clés JSON formatted = formatted.replace(/"(\w+)"\s*:/g, '"$1":'); // Colorer les valeurs importantes formatted = formatted.replace(/: "([^"]+)"/g, ': "$1"'); formatted = formatted.replace(/: (true|false)/g, ': $1'); formatted = formatted.replace(/: (\d+)/g, ': $1'); // Mettre en évidence les lignes de résumé formatted = formatted.replace(/^(PLAY RECAP \*+)$/gm, '$1'); formatted = formatted.replace(/(ok=\d+)/g, '$1'); formatted = formatted.replace(/(changed=\d+)/g, '$1'); formatted = formatted.replace(/(unreachable=\d+)/g, '$1'); formatted = formatted.replace(/(failed=\d+)/g, '$1'); return formatted; } formatAnsibleWarnings(stderr) { let formatted = this.escapeHtml(stderr); // Mettre en évidence les warnings formatted = formatted.replace(/\[WARNING\]:/g, '[WARNING]:'); formatted = formatted.replace(/\[DEPRECATION WARNING\]:/g, '[DEPRECATION WARNING]:'); // Colorer les URLs formatted = formatted.replace(/(https?:\/\/[^\s<]+)/g, '$1'); // Mettre en évidence les chemins de fichiers formatted = formatted.replace(/(\/[\w\-\.\/]+)/g, '$1'); return formatted; } parseOutputByHost(output) { // Parser la sortie Ansible pour séparer par hôte // Format typique: "hostname | STATUS | rc=X >>" const hostOutputs = []; const lines = output.split("\n"); let currentHost = null; let currentOutput = []; let currentStatus = "unknown"; const hostPattern = /^(\S+)\s*\|\s*(CHANGED|SUCCESS|FAILED|UNREACHABLE)\s*\|?\s*rc=(\d+)\s*>>?/; for (const line of lines) { const match = line.match(hostPattern); if (match) { // Sauvegarder l'hôte précédent si existant if (currentHost) { hostOutputs.push({ hostname: currentHost, status: currentStatus, output: currentOutput.join("\n").trim(), }); } // Commencer un nouvel hôte currentHost = match[1]; currentStatus = match[2].toLowerCase(); currentOutput = [line]; } else if (currentHost) { currentOutput.push(line); } else { // Lignes avant le premier hôte (header, etc.) if (!hostOutputs.length && line.trim()) { currentOutput.push(line); } } } // Ajouter le dernier hôte if (currentHost) { hostOutputs.push({ hostname: currentHost, status: currentStatus, output: currentOutput.join("\n").trim(), }); } // Si aucun hôte trouvé, retourner l'output brut if (hostOutputs.length === 0) { return [ { hostname: "output", status: "unknown", output: output, }, ]; } return hostOutputs; } renderHostTabs(hostOutputs, isSuccess) { const stdoutSection = document.getElementById("adhoc-stdout-section"); if (!stdoutSection) return; // Stocker les outputs pour référence this.currentHostOutputs = hostOutputs; // Générer les onglets const tabsHtml = hostOutputs .map((host, index) => { const statusColor = host.status === "changed" || host.status === "success" ? "bg-green-600 hover:bg-green-500" : host.status === "failed" || host.status === "unreachable" ? "bg-red-600 hover:bg-red-500" : "bg-gray-600 hover:bg-gray-500"; const statusIcon = host.status === "changed" || host.status === "success" ? "fa-check" : host.status === "failed" || host.status === "unreachable" ? "fa-times" : "fa-question"; return ` `; }) .join(""); // Générer le compteur d'hôtes const successCount = hostOutputs.filter((h) => h.status === "changed" || h.status === "success").length; const failedCount = hostOutputs.filter((h) => h.status === "failed" || h.status === "unreachable").length; stdoutSection.innerHTML = `
Sortie par hôte (${hostOutputs.length} hôtes: ${successCount} OK ${failedCount > 0 ? `, ${failedCount} échec` : ""})
${tabsHtml}
${this.formatAnsibleOutput(hostOutputs[0].output, isSuccess)}
`; } switchHostTab(index) { if (!this.currentHostOutputs || !this.currentHostOutputs[index]) return; const hostOutput = this.currentHostOutputs[index]; const stdoutPre = document.getElementById("adhoc-stdout"); const tabs = document.querySelectorAll(".host-tab"); // Mettre à jour l'onglet actif tabs.forEach((tab, i) => { if (i === index) { const host = this.currentHostOutputs[i]; const statusColor = host.status === "changed" || host.status === "success" ? "bg-green-600" : host.status === "failed" || host.status === "unreachable" ? "bg-red-600" : "bg-gray-600"; tab.className = `host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all ${statusColor} text-white`; } else { tab.className = "host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all bg-gray-700/50 text-gray-400 hover:text-white"; } }); // Afficher le contenu if (stdoutPre) { stdoutPre.innerHTML = this.formatAnsibleOutput(hostOutput.output, hostOutput.status === "changed" || hostOutput.status === "success"); } } showAllHostsOutput() { if (!this.currentHostOutputs) return; const stdoutPre = document.getElementById("adhoc-stdout"); if (stdoutPre) { const allOutput = this.currentHostOutputs.map((h) => h.output).join("\n\n"); stdoutPre.innerHTML = this.formatAnsibleOutput(allOutput, true); } // Désélectionner tous les onglets document.querySelectorAll(".host-tab").forEach((tab) => { tab.className = "host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all bg-gray-700/50 text-gray-400 hover:text-white"; }); } renderLogs() { const container = document.getElementById("logs-container"); if (!container) return; container.innerHTML = ""; const items = this.logsView === "db" ? this.logs || [] : this.serverLogs || []; const q = (this.currentLogsSearch || "").trim().toLowerCase(); const filteredItems = q ? items.filter((log) => { const timestamp = (log.timestamp || "").toString(); const level = (log.level || "").toString(); const message = (log.message || "").toString(); const source = (log.source || "").toString(); const host = (log.host || "").toString(); const haystack = `${timestamp} ${level} ${message} ${source} ${host}`.toLowerCase(); return haystack.includes(q); }) : items; if (items.length === 0) { container.innerHTML = `

Aucun log disponible

`; return; } if (filteredItems.length === 0) { container.innerHTML = `

Aucun résultat

`; return; } filteredItems.forEach((log) => { const levelColor = this.getLogLevelColor(log.level); // Formater le timestamp depuis l'API (format ISO) const timestamp = log.timestamp ? new Date(log.timestamp).toLocaleString("fr-FR") : "--"; const logEntry = document.createElement("div"); logEntry.className = "log-entry"; logEntry.innerHTML = `
${timestamp} ${log.level} ${log.message} ${log.source ? `[${log.source}]` : ""} ${log.host ? `[${log.host}]` : ""}
`; container.appendChild(logEntry); }); this.scrollLogsToTop(); } getStatusBadge(status) { const badges = { completed: 'SUCCESS', running: 'RUNNING', pending: 'PENDING', failed: 'FAILED', }; return badges[status] || badges["pending"]; } getLogLevelColor(level) { const colors = { INFO: "bg-blue-600 text-white", WARN: "bg-yellow-600 text-white", ERROR: "bg-red-600 text-white", DEBUG: "bg-gray-600 text-white", }; return colors[level] || colors["INFO"]; } startAnimations() { // Animate metrics on load anime({ targets: ".metric-card", translateY: [50, 0], opacity: [0, 1], delay: anime.stagger(200), duration: 800, easing: "easeOutExpo", }); // Animate host cards anime({ targets: ".host-card", translateX: [-30, 0], opacity: [0, 1], delay: anime.stagger(100), duration: 600, easing: "easeOutExpo", }); // Floating animation for hero elements anime({ targets: ".animate-float", translateY: [-10, 10], duration: 3000, direction: "alternate", loop: true, easing: "easeInOutSine", }); } setupScrollAnimations() { const observerOptions = { threshold: 0.1, rootMargin: "0px 0px -50px 0px", }; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.classList.add("visible"); } }); }, observerOptions); document.querySelectorAll(".fade-in").forEach((el) => { observer.observe(el); }); } // Les mises à jour en temps réel sont gérées par WebSocket maintenant // Public methods for UI interactions showQuickActions() { // Construire la liste des groupes Ansible pour le sélecteur const groupOptions = this.ansibleGroups.map((g) => ``).join(""); this.showModal( "Actions Rapides - Ansible", `
`, ); } async executeAnsibleTask(action) { const targetSelect = document.getElementById("ansible-target"); const target = targetSelect ? targetSelect.value : "all"; this.closeModal(); this.showLoading(); try { // Appeler l'API pour créer une tâche Ansible const result = await this.apiCall("/api/tasks", { method: "POST", body: JSON.stringify({ action: action, group: target, dry_run: false, }), }); this.hideLoading(); this.showNotification(`Tâche '${result.name}' lancée sur ${target}`, "success"); // Recharger les données await this.loadAllData(); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } // Legacy function for backward compatibility executeTask(taskType) { // Mapper les anciens types vers les nouvelles actions const actionMap = { "upgrade-all": "upgrade", "reboot-all": "reboot", "health-check": "health-check", backup: "backup", }; const action = actionMap[taskType] || taskType; this.executeAnsibleTask(action); } async executeHostAction(action, hostName) { // hostName peut être un ID (ancien système) ou un nom d'hôte (nouveau système Ansible) let targetHost = hostName; // Si c'est un nombre, chercher par ID (compatibilité) if (typeof hostName === "number") { const host = this.hosts.find((h) => h.id === hostName); if (!host) { this.showNotification("Hôte non trouvé", "error"); return; } targetHost = host.name; } this.showLoading(); try { // Mapper les actions vers les playbooks Ansible const actionMap = { update: "upgrade", upgrade: "upgrade", reboot: "reboot", connect: "health-check", "health-check": "health-check", backup: "backup", }; const ansibleAction = actionMap[action]; if (ansibleAction) { const result = await this.apiCall("/api/tasks", { method: "POST", body: JSON.stringify({ action: ansibleAction, host: targetHost, dry_run: false, }), }); this.hideLoading(); this.showNotification(`Tâche '${result.name}' lancée sur ${targetHost}`, "success"); await this.loadAllData(); } else { this.hideLoading(); this.showNotification(`Action '${action}' non supportée`, "warning"); } } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } // Collect metrics for all hosts async collectAllMetrics() { this.showLoading(); try { const result = await this.apiCall("/api/builtin-playbooks/collect-all", { method: "POST", }); this.hideLoading(); this.showNotification(`Collecte de métriques démarrée pour ${result.hosts_count || "tous les"} hôtes`, "success"); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } // Show global playbook modal to run playbook on all hosts showGlobalPlaybookModal() { // Get available playbooks const playbooks = this.playbooks || []; const playbookOptions = playbooks.length > 0 ? playbooks.map((p) => ``).join("") : ''; this.showModal( "Exécuter un Playbook sur tous les hôtes", `

Exécution globale

Ce playbook sera exécuté sur tous les hôtes configurés (Ansible Ready).

`, ); } // Execute playbook on all hosts async executeGlobalPlaybook(event) { event.preventDefault(); const formData = new FormData(event.target); const playbookName = formData.get("playbook_name"); if (!playbookName) { this.showNotification("Veuillez sélectionner un playbook", "warning"); return; } this.closeModal(); this.showLoading(); try { const result = await this.apiCall("/api/tasks", { method: "POST", body: JSON.stringify({ action: playbookName, host: "all", dry_run: false, }), }); this.hideLoading(); this.showNotification(`Playbook '${result.name}' lancé sur tous les hôtes`, "success"); await this.loadAllData(); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } // Redirige vers la modale avancée d'ajout d'hôte (avec groupes env/role et écriture dans hosts.yml) addHost() { this.showAddHostModal(); } showBootstrapModal(hostName, hostIp) { this.showModal( `Bootstrap SSH - ${hostName}`, `

Cette opération va :

  • Créer l'utilisateur d'automatisation
  • Configurer l'authentification SSH par clé
  • Installer et configurer sudo
  • Installer Python3 (requis par Ansible)

Utilisé uniquement pour la configuration initiale

`, ); } async executeBootstrap(event) { event.preventDefault(); const formData = new FormData(event.target); const host = formData.get("host"); const rootPassword = formData.get("root_password"); const automationUser = formData.get("automation_user") || "automation"; this.closeModal(); this.showLoading(); try { const result = await this.apiCall("/api/ansible/bootstrap", { method: "POST", body: JSON.stringify({ host: host, root_password: rootPassword, automation_user: automationUser, }), }); this.hideLoading(); // Afficher le résultat dans un modal this.showModal( "Bootstrap Réussi", `

Configuration terminée!

L'hôte ${host} est prêt pour Ansible

Détails

${result.stdout || "Pas de sortie"}
`, ); this.showNotification(`Bootstrap réussi pour ${host}`, "success"); await this.loadAllData(); } catch (error) { this.hideLoading(); // Extraire les détails de l'erreur let errorDetail = error.message; let stdout = ""; let stderr = ""; if (error.detail && typeof error.detail === "object") { stdout = error.detail.stdout || ""; stderr = error.detail.stderr || ""; } this.showModal( "Erreur Bootstrap", `

Bootstrap échoué

${errorDetail}

${stderr ? `

Erreur

${stderr}
` : "" } ${stdout ? `

Sortie

${stdout}
` : "" }
`, ); } } manageHost(hostNameOrId) { // Support both host name and ID let host; if (typeof hostNameOrId === "number") { host = this.hosts.find((h) => h.id === hostNameOrId); } else { host = this.hosts.find((h) => h.name === hostNameOrId); } if (!host) return; const lastSeen = host.last_seen ? new Date(host.last_seen).toLocaleString("fr-FR") : "Jamais vérifié"; // Bootstrap status const bootstrapOk = host.bootstrap_ok || false; const bootstrapDate = host.bootstrap_date ? new Date(host.bootstrap_date).toLocaleString("fr-FR") : null; const bootstrapStatusHtml = bootstrapOk ? `
Ansible Ready (${bootstrapDate || "N/A"})
` : `
Non configuré - Bootstrap requis
`; this.showModal( `Gérer ${host.name}`, `

Informations de l'hôte

Nom:

${host.name}

IP:

${host.ip}

OS:

${host.os}

Statut:

${host.status}

Dernière connexion:

${lastSeen}

Statut Bootstrap Ansible

${bootstrapStatusHtml}
`, ); } removeHost(hostId) { if (confirm("Êtes-vous sûr de vouloir supprimer cet hôte?")) { this.hosts = this.hosts.filter((h) => h.id !== hostId); this.renderHosts(); this.closeModal(); this.showNotification("Hôte supprimé avec succès!", "success"); } } addTaskToList(taskType) { const taskNames = { "upgrade-all": "Mise à jour système", "reboot-all": "Redémarrage système", "health-check": "Vérification de santé", backup: "Sauvegarde", }; const newTask = { id: Math.max(...this.tasks.map((t) => t.id)) + 1, name: taskNames[taskType] || "Tâche inconnue", host: "Multiple", status: "running", progress: 0, startTime: new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }), duration: "0s", }; this.tasks.unshift(newTask); this.renderTasks(); // Simulate task completion setTimeout(() => { const task = this.tasks.find((t) => t.id === newTask.id); if (task) { task.status = "completed"; task.progress = 100; this.renderTasks(); } }, 5000); } stopTask(taskId) { const task = this.tasks.find((t) => t.id === taskId); if (task && task.status === "running") { task.status = "failed"; task.progress = 0; this.renderTasks(); this.showNotification("Tâche arrêtée", "warning"); } } viewTaskDetails(taskId) { const task = this.tasks.find((t) => t.id === taskId); if (!task) return; this.showModal( `Détails de la tâche`, `

${task.name}

Hôte: ${task.host}

Statut: ${this.getStatusBadge(task.status)}

Progression: ${task.progress}%

Durée: ${task.duration}

Logs de la tâche

• Démarrage de la tâche...

• Connexion SSH établie

• Exécution des commandes...

• Tâche terminée avec succès

`, ); } refreshTasks() { this.showLoading(); setTimeout(() => { this.hideLoading(); this.showNotification("Tâches rafraîchies", "success"); }, 1000); } clearLogs() { if (confirm("Êtes-vous sûr de vouloir effacer tous les logs?")) { this.logs = []; this.renderLogs(); this.showNotification("Logs effacés avec succès!", "success"); } } exportLogs() { const items = this.logsView === "db" ? this.logs || [] : this.serverLogs || []; const logText = items.map((log) => `${log.timestamp} [${log.level}] ${log.message}`).join("\n"); const blob = new Blob([logText], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `homelab-logs-${new Date().toISOString().slice(0, 10)}.txt`; a.click(); URL.revokeObjectURL(url); this.showNotification("Logs exportés avec succès!", "success"); } async downloadHelpDocumentation(format) { try { const safeFormat = (format || "").toLowerCase(); if (safeFormat !== "md" && safeFormat !== "pdf") { throw new Error("Format de documentation non supporté"); } const endpoint = safeFormat === "pdf" ? `${this.apiBase}/api/help/documentation.pdf` : `${this.apiBase}/api/help/documentation.md`; const response = await fetch(endpoint, { method: "GET", headers: this.getAuthHeaders(), }); if (!response.ok) { if (response.status === 401) { this.showNotification("Connexion requise", "error"); this.logout(); return; } const text = await response.text(); throw new Error(text || "Échec du téléchargement"); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = safeFormat === "pdf" ? "homelab-documentation.pdf" : "homelab-documentation.md"; a.click(); URL.revokeObjectURL(url); this.showNotification("Documentation téléchargée", "success"); } catch (error) { console.error("Help download failed:", error); this.showNotification(error.message || "Échec du téléchargement", "error"); } } // ===== GESTION DES PLAYBOOKS ===== // Cache des résultats lint (chargé depuis l'API backend) playbookLintResults = {}; async loadPlaybookLintResults() { /** * Charge tous les résultats de lint depuis l'API backend. * Appelé au démarrage et après chaque lint. */ try { const data = await this.apiCall("/api/playbooks/results"); this.playbookLintResults = data?.results || {}; console.log("[Dashboard] Loaded lint results for", Object.keys(this.playbookLintResults).length, "playbooks"); } catch (e) { console.warn("[Dashboard] Failed to load lint results from API:", e); // Fallback: essayer localStorage this.playbookLintResults = this.getPlaybookLintResultsFromLocalStorage(); } } getPlaybookLintStorageKey() { return "playbookLintResults:v1"; } getPlaybookLintResultsFromLocalStorage() { try { const raw = localStorage.getItem(this.getPlaybookLintStorageKey()); if (!raw) return {}; const map = JSON.parse(raw); // Convertir au format attendu (result -> direct) const converted = {}; for (const [filename, entry] of Object.entries(map)) { if (entry?.result) { converted[filename] = { ...entry.result, updated_at: entry.stored_at, }; } } return converted; } catch (e) { return {}; } } storePlaybookLintResult(filename, lintResult) { // Mettre à jour le cache local immédiatement this.playbookLintResults[filename] = { ...lintResult, updated_at: new Date().toISOString(), }; // Aussi sauvegarder en localStorage comme fallback try { const key = this.getPlaybookLintStorageKey(); const raw = localStorage.getItem(key); const map = raw ? JSON.parse(raw) : {}; map[filename] = { stored_at: new Date().toISOString(), result: lintResult, }; localStorage.setItem(key, JSON.stringify(map)); } catch (e) { console.warn("Failed to store lint result in localStorage:", e); } } getPlaybookLintResult(filename) { // Utiliser le cache chargé depuis l'API const result = this.playbookLintResults?.[filename]; if (result) { return { result, stored_at: result.updated_at }; } return null; } async copyPlaybookLintToClipboard(filename) { const lintData = this.playbookLintResults?.[filename]; if (!lintData) { this.showNotification("Aucun résultat lint à copier (exécutez Lint d'abord)", "warning"); return; } const payload = { filename, ...lintData, }; const text = JSON.stringify(payload, null, 2); try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); } else { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); } this.showNotification(`Résultat ansible-lint copié (${filename})`, "success"); } catch (e) { this.showNotification("Impossible de copier dans le presse-papiers", "error"); } } renderPlaybooks() { const container = document.getElementById("playbooks-list"); if (!container) return; // Filtrer les playbooks let filteredPlaybooks = this.playbooks; // Filtre par catégorie if (this.currentPlaybookCategoryFilter && this.currentPlaybookCategoryFilter !== "all") { filteredPlaybooks = filteredPlaybooks.filter((pb) => (pb.category || "general").toLowerCase() === this.currentPlaybookCategoryFilter.toLowerCase()); } // Filtre par recherche if (this.currentPlaybookSearch) { const search = this.currentPlaybookSearch.toLowerCase(); filteredPlaybooks = filteredPlaybooks.filter((pb) => pb.name.toLowerCase().includes(search) || pb.filename.toLowerCase().includes(search) || (pb.description || "").toLowerCase().includes(search)); } // Mettre à jour le compteur const countEl = document.getElementById("playbooks-count"); if (countEl) { countEl.innerHTML = `${filteredPlaybooks.length} inventory items`; } if (filteredPlaybooks.length === 0) { container.innerHTML = `

Aucun playbook trouvé

Essayez de modifier vos filtres ou créez-en un nouveau.

`; return; } container.innerHTML = filteredPlaybooks.map((pb) => this.createPlaybookCardHTML(pb)).join(""); this.updatePlaybookCategoryFilters(); } updatePlaybookCategoryFilters() { const container = document.getElementById("playbook-category-filters"); if (!container) return; const categorySet = new Set(); (this.playbooks || []).forEach((pb) => categorySet.add((pb.category || "general").toLowerCase())); const categories = Array.from(categorySet).sort(); const buttonsHtml = [ ``, ...categories.map((cat) => { const isActive = this.currentPlaybookCategoryFilter && this.currentPlaybookCategoryFilter.toLowerCase() === cat; const label = this.getCategoryLabel(cat).toUpperCase(); return ` `; }), ].join(""); container.innerHTML = `
${buttonsHtml}
`; } createPlaybookCardHTML(playbook) { const category = (playbook.category || "general").toLowerCase(); const categoryLabel = this.getCategoryLabel(category).toUpperCase(); const sizeKb = playbook.size ? (playbook.size / 1024).toFixed(1) : "?"; const modifiedAgo = playbook.modified ? this.getRelativeTime(playbook.modified) : "Unknown"; const lintData = this.playbookLintResults?.[playbook.filename] || null; const qualityScore = typeof lintData?.quality_score === "number" ? lintData.quality_score : null; let qualityBadge = ""; if (qualityScore !== null) { let color = "bg-green-500/10 text-green-400 border-green-500/20"; if (qualityScore < 50) color = "bg-red-500/10 text-red-400 border-red-500/20"; else if (qualityScore < 80) color = "bg-yellow-500/10 text-yellow-400 border-yellow-500/20"; qualityBadge = `Q:${qualityScore}`; } return `

${this.escapeHtml(playbook.filename)}

${categoryLabel} ${qualityBadge}
${playbook.description ? `

${this.escapeHtml(playbook.description)}

` : '
'}
${modifiedAgo} ${sizeKb}KB
`; } getCategoryLabel(category) { const labels = { maintenance: "Maintenance", deploy: "Deploy", backup: "Backup", monitoring: "Monitoring", system: "System", general: "Général", testing: "Testing", }; return labels[category] || category; } getPlaybookCategoryIcon(category) { const icons = { maintenance: "fa-wrench", deploy: "fa-rocket", backup: "fa-save", monitoring: "fa-heartbeat", system: "fa-cogs", testing: "fa-flask", }; return icons[category] || null; } getRelativeTime(dateString) { if (!dateString) return "Date inconnue"; const date = new Date(dateString); const now = new Date(); const diffMs = now - date; const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); if (diffSec < 60) return "À l'instant"; if (diffMin < 60) return `il y a ${diffMin} min`; if (diffHour < 24) return `il y a ${diffHour} h`; if (diffDay === 1) return "Hier"; if (diffDay < 7) return `il y a ${diffDay} jours`; if (diffDay < 30) return `il y a ${Math.floor(diffDay / 7)} sem.`; return date.toLocaleDateString("fr-FR"); } filterPlaybooks(searchText) { this.currentPlaybookSearch = searchText; this.renderPlaybooks(); } filterPlaybooksByCategory(category) { this.currentPlaybookCategoryFilter = category; // Mettre à jour les boutons de filtre document.querySelectorAll(".pro-btn").forEach((btn) => { const btnCategory = btn.getAttribute("onclick")?.match(/'([^']+)'/)?.[1]; if (btnCategory === category) { btn.classList.add("active", "ring-1", "ring-violet-500/50"); } else { btn.classList.remove("active", "ring-1", "ring-violet-500/50"); } }); this.renderPlaybooks(); } async refreshPlaybooks() { this.showLoading(); try { const playbooksData = await this.apiCall("/api/ansible/playbooks"); this.playbooks = playbooksData.playbooks || []; this.playbookCategories = playbooksData.categories || {}; this.renderPlaybooks(); this.hideLoading(); this.showNotification("Playbooks rechargés", "success"); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message} `, "error"); } } async editPlaybook(filename) { this.showLoading(); try { const result = await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}/content`); this.hideLoading(); this.showPlaybookEditor(filename, result.content, false); } catch (error) { this.hideLoading(); this.showNotification(`Erreur chargement playbook: ${error.message}`, "error"); } } showCreatePlaybookModal() { const defaultContent = `--- # Nouveau Playbook Ansible # Documentation: https://docs.ansible.com/ansible/latest/playbook_guide/ - name: Mon nouveau playbook hosts: all become: yes vars: category: general subcategory: other tasks: - name: Exemple de tâche ansible.builtin.debug: msg: "Hello from Ansible!" `; this.showModal( "Créer un Playbook", `
.yml

Lettres, chiffres, tirets et underscores uniquement

`, ); } async createNewPlaybook() { const nameInput = document.getElementById("new-playbook-name"); const name = nameInput?.value.trim(); if (!name) { this.showNotification("Veuillez saisir un nom de fichier", "warning"); return; } if (!/^[a-zA-Z0-9_-]+$/.test(name)) { this.showNotification("Nom invalide: utilisez uniquement lettres, chiffres, tirets et underscores", "error"); return; } const filename = `${name}.yml`; // Vérifier si le fichier existe déjà const exists = this.playbooks.some((pb) => pb.filename.toLowerCase() === filename.toLowerCase()); if (exists) { this.showNotification(`Le playbook "${filename}" existe déjà`, "error"); return; } const defaultContent = `--- # ${filename} # Créé le ${new Date().toLocaleDateString("fr-FR")} - name: ${name.replace(/-/g, " ").replace(/_/g, " ")} hosts: all become: yes vars: category: general subcategory: other tasks: - name: Exemple de tâche ansible.builtin.debug: msg: "Playbook ${name} exécuté avec succès!" `; // On ouvre directement l'éditeur avec le contenu par défaut this.showPlaybookEditor(filename, defaultContent, true); } showPlaybookEditor(filename, content, isNew = false) { const title = isNew ? `Créer: ${filename}` : `Modifier: ${filename}`; const modalContent = `
${this.escapeHtml(filename)} ${isNew ? 'Nouveau' : ""}
YAML valide
`; this.showModal(title, modalContent); // Ajouter classe pour modal large setTimeout(() => { const modalCard = document.querySelector("#modal .glass-card"); if (modalCard) { modalCard.classList.add("playbook-editor-modal"); } }, 10); // Initialiser l'éditeur CodeMirror setTimeout(() => { if (window.PlaybookEditor && window.PlaybookEditor.init) { // Utiliser CodeMirror avec lint console.log("[Dashboard] Initializing CodeMirror editor"); window.PlaybookEditor.init("editor-panel", content, filename, isNew); } else { // Fallback: créer un textarea basique console.log("[Dashboard] CodeMirror not available, using textarea fallback"); const container = document.getElementById("editor-panel"); if (container) { container.innerHTML = ``; const textarea = document.getElementById("playbook-editor-content"); if (textarea) { textarea.addEventListener("input", () => this.validateYamlContent(textarea.value)); textarea.addEventListener("keydown", (e) => { if (e.key === "Tab") { e.preventDefault(); const start = textarea.selectionStart; const end = textarea.selectionEnd; textarea.value = textarea.value.substring(0, start) + " " + textarea.value.substring(end); textarea.selectionStart = textarea.selectionEnd = start + 2; } }); } } } // Setup du bouton lint - appeler directement PlaybookEditor si initialisé const lintBtn = document.getElementById("lint-button"); if (lintBtn) { lintBtn.addEventListener("click", async () => { if (window.PlaybookEditor?.state?.initialized) { await window.PlaybookEditor.runLint(); } else { await this.runAnsibleLint(filename); } }); } }, 100); } switchEditorTab(tabName) { const tabs = document.querySelectorAll(".editor-tab"); tabs.forEach((tab) => { tab.classList.toggle("active", tab.dataset.tab === tabName); }); const editorPanel = document.getElementById("editor-panel"); const problemsPanel = document.getElementById("problems-panel-container"); if (tabName === "editor") { editorPanel?.classList.remove("hidden"); problemsPanel?.classList.add("hidden"); } else { editorPanel?.classList.add("hidden"); problemsPanel?.classList.remove("hidden"); } } async runAnsibleLint(filename) { // Utiliser PlaybookEditor si initialisé if (window.PlaybookEditor?.state?.initialized) { console.log("[Dashboard] Using PlaybookEditor.runLint()"); await window.PlaybookEditor.runLint(); return; } // Fallback: récupérer le contenu du textarea const textarea = document.getElementById("playbook-editor-content"); const content = textarea?.value || ""; if (!content.trim()) { this.showNotification("Le contenu est vide", "warning"); return; } console.log("[Dashboard] Fallback lint, content length:", content.length); // Fallback: appel API direct const lintBtn = document.getElementById("lint-button"); if (lintBtn) { lintBtn.innerHTML = ' Analyse...'; lintBtn.disabled = true; } try { const result = await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}/lint`, { method: "POST", body: JSON.stringify({ content }), }); // Mettre à jour l'UI this.updateLintResults(result); } catch (error) { console.error("Lint error:", error); if (lintBtn) { lintBtn.innerHTML = ' Lint'; lintBtn.disabled = false; } if (!error.message.includes("503")) { this.showNotification(`Erreur lint: ${error.message}`, "error"); } } } updateLintResults(result) { const lintBtn = document.getElementById("lint-button"); const problemsCount = document.getElementById("problems-count"); const problemsTab = document.getElementById("problems-tab"); const problemsPanel = document.getElementById("problems-panel"); const qualityBadge = document.getElementById("quality-badge"); const { summary, quality_score, issues, execution_time_ms } = result; // Persist last lint for this playbook (so playbooks list can show score) try { const titleEl = document.querySelector(".editor-title"); const filename = titleEl?.textContent?.replace(/\s*•\s*$/, "") || null; if (filename) { this.storePlaybookLintResult(filename, result); // Refresh playbooks list immediately this.renderPlaybooks(); } } catch (e) { // no-op } // Mettre à jour le bouton if (lintBtn) { lintBtn.disabled = false; if (summary.errors > 0) { lintBtn.innerHTML = `${summary.total}`; lintBtn.className = "lint-button errors"; } else if (summary.warnings > 0) { lintBtn.innerHTML = `${summary.total}`; lintBtn.className = "lint-button warnings"; } else { lintBtn.innerHTML = ' OK'; lintBtn.className = "lint-button success"; } } // Mettre à jour le compteur de problèmes if (problemsCount) { problemsCount.textContent = summary.total; } // Mettre à jour l'onglet problèmes if (problemsTab) { problemsTab.classList.remove("errors", "warnings"); if (summary.errors > 0) { problemsTab.classList.add("errors"); } else if (summary.warnings > 0) { problemsTab.classList.add("warnings"); } } // Mettre à jour le badge de qualité if (qualityBadge) { let colorClass = "excellent"; if (quality_score < 50) colorClass = "poor"; else if (quality_score < 70) colorClass = "warning"; else if (quality_score < 90) colorClass = "good"; qualityBadge.innerHTML = `Quality: ${quality_score}/100`; qualityBadge.style.display = "block"; } // Mettre à jour le panneau des problèmes if (problemsPanel) { if (issues.length === 0) { problemsPanel.innerHTML = `

Aucun problème détecté

Temps d'exécution: ${execution_time_ms}ms

`; } else { let html = '
'; for (const issue of issues) { const severityIcon = { error: '', warning: '', info: '', }[issue.severity] || ''; html += `
${severityIcon}
${this.escapeHtml(issue.rule_id)} Ligne ${issue.line}

${this.escapeHtml(issue.message)}

${issue.fix_suggestion ? `

${this.escapeHtml(issue.fix_suggestion)}

` : "" }
${issue.help_url ? ` ` : "" }
`; } html += "
"; html += `
Temps d'exécution: ${execution_time_ms}ms
`; problemsPanel.innerHTML = html; } } } goToEditorLine(lineNumber) { // Utiliser PlaybookEditor si disponible if (window.PlaybookEditor?.goToLine) { window.PlaybookEditor.goToLine(lineNumber); this.switchEditorTab("editor"); return; } // Fallback textarea const textarea = document.getElementById("playbook-editor-content"); if (textarea) { const lines = textarea.value.split("\n"); let pos = 0; for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) { pos += lines[i].length + 1; } textarea.setSelectionRange(pos, pos + (lines[lineNumber - 1]?.length || 0)); textarea.focus(); this.switchEditorTab("editor"); } } async savePlaybookEnhanced(filename, isNew = false) { // Utiliser PlaybookEditor si initialisé if (window.PlaybookEditor?.state?.initialized) { console.log("[Dashboard] Using PlaybookEditor.save()"); const success = await window.PlaybookEditor.save(); if (success) { this.closeModal(); await this.refreshPlaybooks(); } return; } // Fallback vers la méthode originale console.log("[Dashboard] Fallback to savePlaybook()"); await this.savePlaybook(filename, isNew); } validateYamlContent(content) { const statusEl = document.getElementById("yaml-validation-status"); if (!statusEl) return; // Validation basique du YAML const errors = []; const lines = content.split("\n"); lines.forEach((line, index) => { // Vérifier les tabs (doit utiliser des espaces) if (line.includes("\t")) { errors.push(`Ligne ${index + 1}: Utilisez des espaces au lieu des tabs`); } // Vérifier l'indentation impaire const leadingSpaces = line.match(/^(\s*)/)[1].length; if (leadingSpaces % 2 !== 0 && line.trim()) { errors.push(`Ligne ${index + 1}: Indentation impaire détectée`); } }); if (errors.length > 0) { statusEl.innerHTML = ` ${errors[0]} `; } else { statusEl.innerHTML = ` YAML valide `; } } async savePlaybook(filename, isNew = false) { const textarea = document.getElementById("playbook-editor-content"); if (!textarea) return; const content = textarea.value; if (!content.trim()) { this.showNotification("Le contenu ne peut pas être vide", "warning"); return; } this.showLoading(); try { const result = await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}/content`, { method: "PUT", body: JSON.stringify({ content: content }), }); this.hideLoading(); this.closeModal(); // Retirer la classe spéciale const modalCard = document.querySelector("#modal .glass-card"); if (modalCard) { modalCard.classList.remove("playbook-editor-modal"); } this.showNotification(isNew ? `Playbook "${filename}" créé avec succès` : `Playbook "${filename}" sauvegardé`, "success"); // Rafraîchir la liste await this.refreshPlaybooks(); } catch (error) { this.hideLoading(); this.showNotification(`Erreur sauvegarde: ${error.message}`, "error"); } } async runPlaybook(filename) { // Ouvrir le modal d'exécution pour un playbook existant const targetOptions = ['', ...this.ansibleGroups.map((g) => ``)].join(""); this.showModal( `Exécuter: ${this.escapeHtml(filename)}`, `
Configurez les options d'exécution du playbook
`, ); } async executePlaybookFromModal(filename) { const target = document.getElementById("run-playbook-target")?.value || "all"; const varsText = document.getElementById("run-playbook-vars")?.value || ""; const checkMode = document.getElementById("run-playbook-check")?.checked || false; const verbose = document.getElementById("run-playbook-verbose")?.checked || false; let extraVars = {}; if (varsText.trim()) { try { extraVars = JSON.parse(varsText); } catch (e) { this.showNotification("Variables JSON invalides", "error"); return; } } this.closeModal(); this.showLoading(); try { const result = await this.apiCall("/api/ansible/execute", { method: "POST", body: JSON.stringify({ playbook: filename, target: target, extra_vars: extraVars, check_mode: checkMode, verbose: verbose, }), }); this.hideLoading(); this.showNotification(`Playbook exécuté sur ${target} (tâche ${result.task_id})`, "success"); // Aller sur l'onglet Tâches et rafraîchir this.setActiveNav("tasks"); await this.loadTaskLogsWithFilters(); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, "error"); } } confirmDeletePlaybook(filename) { this.showModal( "Confirmer la suppression", `

Attention !

Vous êtes sur le point de supprimer le playbook ${this.escapeHtml(filename)}.

Cette action est irréversible.

`, ); } async deletePlaybook(filename) { this.closeModal(); this.showLoading(); try { await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}`, { method: "DELETE", }); this.hideLoading(); this.showNotification(`Playbook "${filename}" supprimé`, "success"); // Rafraîchir la liste await this.refreshPlaybooks(); } catch (error) { this.hideLoading(); this.showNotification(`Erreur suppression: ${error.message}`, "error"); } } showModal(title, content, options = {}) { const modalCard = document.querySelector("#modal .glass-card"); document.getElementById("modal-title").textContent = title; document.getElementById("modal-content").innerHTML = content; document.getElementById("modal").classList.remove("hidden"); // Appliquer classe spéciale pour Ad-Hoc console if (title.includes("Ad-Hoc")) { modalCard.classList.add("adhoc-modal"); } else { modalCard.classList.remove("adhoc-modal"); } // Animate modal appearance anime({ targets: "#modal .glass-card", scale: [0.8, 1], opacity: [0, 1], duration: 300, easing: "easeOutExpo", }); } // ===== MÉTHODES DU WIDGET AD-HOC ===== adhocWidgetLimit = 5; adhocWidgetOffset = 0; /** * Rendu du widget Console Ad-Hoc sur le dashboard */ renderAdhocWidget() { const historyContainer = document.getElementById("adhoc-widget-history"); const loadMoreBtn = document.getElementById("adhoc-widget-load-more"); const successEl = document.getElementById("adhoc-widget-success"); const failedEl = document.getElementById("adhoc-widget-failed"); const totalEl = document.getElementById("adhoc-widget-total"); const countEl = document.getElementById("adhoc-widget-count"); if (!historyContainer) return; // Calculer les stats const total = Array.isArray(this.adhocWidgetLogs) ? this.adhocWidgetLogs.length : 0; const success = (this.adhocWidgetLogs || []).filter((l) => (l.status || "").toLowerCase() === "completed").length; const failed = (this.adhocWidgetLogs || []).filter((l) => (l.status || "").toLowerCase() === "failed").length; // Mettre à jour les stats if (successEl) successEl.textContent = success; if (failedEl) failedEl.textContent = failed; if (totalEl) totalEl.textContent = total; if (countEl) countEl.textContent = this.adhocWidgetTotalCount > 0 ? `${this.adhocWidgetTotalCount} exécution${this.adhocWidgetTotalCount > 1 ? "s" : ""}` : ""; // Afficher les dernières exécutions const displayedLimit = this.adhocWidgetLimit + this.adhocWidgetOffset; const displayedLogs = (this.adhocWidgetLogs || []).slice(0, displayedLimit); if (displayedLogs.length === 0) { historyContainer.innerHTML = `

Aucune exécution

Ouvrez la console pour exécuter des commandes

`; if (loadMoreBtn) loadMoreBtn.classList.add("hidden"); return; } historyContainer.innerHTML = displayedLogs .map((log) => { const status = (log.status || "").toLowerCase(); const isSuccess = status === "completed"; const statusColor = isSuccess ? "text-green-400" : "text-red-400"; const statusBg = isSuccess ? "bg-green-900/30 border-green-700/50" : "bg-red-900/30 border-red-700/50"; const statusIcon = isSuccess ? "fa-check-circle" : "fa-times-circle"; const statusText = isSuccess ? "Succès" : "Échec"; // Formater la date const date = log.created_at ? new Date(log.created_at) : new Date(); const timeAgo = this.formatTimeAgo(date); // Extraire le nom de la commande (première partie avant |) const taskName = (log.task_name || "").trim(); const cmdName = taskName ? taskName.replace(/^ad-?hoc\s*:\s*/i, "").split(" ")[0] : "Ad-hoc"; // Trouver la catégorie const catColor = "#60a5fa"; return `
${this.escapeHtml(cmdName)} Ad-hoc
${this.escapeHtml(log.target || "all")} ${timeAgo} ${log.duration_seconds ? `${Number(log.duration_seconds).toFixed(1)}s` : ""}
`; }) .join(""); // Afficher/masquer le bouton "Charger plus" if (loadMoreBtn) { const moreToDisplayLocally = (this.adhocWidgetLogs || []).length > displayedLogs.length; const moreOnServer = Boolean(this.adhocWidgetHasMore); if (moreToDisplayLocally || moreOnServer) { loadMoreBtn.classList.remove("hidden"); const remaining = Math.max(0, this.adhocWidgetTotalCount - displayedLogs.length); loadMoreBtn.innerHTML = `Charger plus (${remaining} restantes)`; } else { loadMoreBtn.classList.add("hidden"); } } } renderFavoriteContainersWidget() { const container = document.getElementById("dashboard-favorites"); const countEl = document.getElementById("dashboard-favorites-count"); const searchInput = document.getElementById("dashboard-favorites-search"); const clearBtn = document.getElementById("dashboard-favorites-search-clear"); if (!container) return; const fm = window.favoritesManager; if (!fm) { container.innerHTML = `

Favoris indisponibles

`; return; } let favorites = Array.from(fm.favoritesById.values()); const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : ""; if (searchTerm) { favorites = favorites.filter((f) => { const dc = f.docker_container; if (!dc) return false; return (dc.name || "").toLowerCase().includes(searchTerm) || (dc.host_name || "").toLowerCase().includes(searchTerm) || (dc.image || "").toLowerCase().includes(searchTerm); }); } if (clearBtn) { clearBtn.classList.toggle("hidden", !searchTerm); } if (countEl) { countEl.textContent = favorites.length > 0 ? `${favorites.length} favori${favorites.length > 1 ? "s" : ""}` : ""; } if (favorites.length === 0) { container.innerHTML = `

Aucun container favori

Ajoute des favoris depuis la page Containers

`; return; } const groups = Array.isArray(fm.groups) ? fm.groups.slice() : []; groups.sort((a, b) => { const ao = Number(a.sort_order || 0); const bo = Number(b.sort_order || 0); if (ao !== bo) return ao - bo; return String(a.name || "").localeCompare(String(b.name || ""), "fr"); }); const groupsById = new Map(groups.map((g) => [g.id, g])); const grouped = new Map(); const uncategorizedKey = "uncategorized"; favorites.forEach((f) => { const gid = f.group_id; const key = gid ? String(gid) : uncategorizedKey; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(f); }); const orderedGroupKeys = []; groups.forEach((g) => { const key = String(g.id); if (grouped.has(key)) orderedGroupKeys.push(key); }); if (grouped.has(uncategorizedKey)) orderedGroupKeys.push(uncategorizedKey); const stateColor = (state) => { const s = String(state || "").toLowerCase(); if (s === "running") return "green"; if (s === "paused") return "yellow"; if (s === "restarting") return "orange"; if (s === "created") return "blue"; if (s === "exited" || s === "dead") return "red"; return "gray"; }; const portLinks = (ports, hostIp) => { if (!ports || !hostIp) return ""; const portStr = ports.raw || (typeof ports === "string" ? ports : ""); if (!portStr) return ""; const portRegex = /(?:([\d.]+):)?(\d+)->\d+\/tcp/g; const links = []; const seen = new Set(); let match; while ((match = portRegex.exec(portStr)) !== null) { const bindIp = match[1] || "0.0.0.0"; const hostPort = match[2]; if (bindIp === "127.0.0.1" || bindIp === "::1") continue; if (seen.has(hostPort)) continue; seen.add(hostPort); const protocol = ["443", "8443", "9443"].includes(hostPort) ? "https" : "http"; const url = `${protocol}://${hostIp}:${hostPort}`; links.push(` ${hostPort} `); } return links.slice(0, 4).join(""); }; const groupTitle = (key) => { if (key === uncategorizedKey) return { name: "Non classé", color: null, icon_key: null }; const g = groupsById.get(Number(key)); return { name: g?.name || "Groupe", color: g?.color || null, icon_key: g?.icon_key || null }; }; const groupHtml = orderedGroupKeys .map((key) => { const title = groupTitle(key); const items = grouped.get(key) || []; const iconColor = title.color || "#7c3aed"; const iconKey = title.icon_key || "lucide:star"; const headerPill = ``; return `
${headerPill} ${this.escapeHtml(title.name)} ${items.length}
${items .map((f) => { const dc = f.docker_container; if (!dc) return ""; const hostStatus = (dc.host_docker_status || "").toLowerCase(); const hostOffline = hostStatus && hostStatus !== "online"; const dotColor = stateColor(dc.state); const isRunning = String(dc.state || "").toLowerCase() === "running"; const favId = f.id; const disabledClass = hostOffline ? "opacity-30 grayscale" : ""; const btnDisabled = hostOffline ? "disabled" : ""; const custom = window.containerCustomizationsManager?.get(dc.host_id, dc.container_id); const iconKey = custom?.icon_key || ""; const iconColor = custom?.icon_color || "#9ca3af"; const bgColor = custom?.bg_color || ""; const bgStyle = bgColor ? `background:${this.escapeHtml(bgColor)};` : ""; const iconHtml = iconKey ? `
` : ""; return `
${iconHtml}
${this.escapeHtml(dc.name)}
${this.escapeHtml(dc.host_name)} ${this.escapeHtml(dc.status || dc.state || "")}
${portLinks(dc.ports, dc.host_ip)}
`; }) .join("")}
`; }) .join(""); container.innerHTML = groupHtml; } async favoriteContainerAction(hostId, containerId, action) { try { this.showNotification(`${action}...`, "info"); const result = await this.apiCall(`/api/docker/containers/${encodeURIComponent(hostId)}/${encodeURIComponent(containerId)}/${encodeURIComponent(action)}`, { method: "POST", }); if (result && result.success) { this.showNotification(`Action ${action} OK`, "success"); } else { this.showNotification(`Erreur: ${result?.error || result?.message || "Échec"}`, "error"); } } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } finally { if (window.favoritesManager) { window.favoritesManager.load().catch(() => null); } } } async removeFavoriteContainer(favoriteId) { if (!window.favoritesManager) return; try { await window.favoritesManager.ensureInit(); await window.favoritesManager.removeFavoriteById(Number(favoriteId)); this.showNotification("Favori retiré", "success"); } catch (e) { this.showNotification(`Erreur favoris: ${e.message}`, "error"); } } async showMoveFavoriteModal(hostId, containerId) { const fm = window.favoritesManager; if (!fm) return; try { await fm.ensureInit(); await fm.listGroups(); const groups = fm.groups || []; const options = [``, ...groups.map((g) => ``)].join(""); this.showModal( "Déplacer le favori", `
`, ); } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } } async moveFavoriteToGroup(event, hostId, containerId) { event.preventDefault(); const fm = window.favoritesManager; if (!fm) return; const select = document.getElementById("favorite-move-group"); const raw = select ? select.value : ""; const groupId = raw ? Number(raw) : null; try { await fm.ensureInit(); await fm.moveFavoriteToGroup(hostId, containerId, groupId); this.closeModal(); this.showNotification("Favori déplacé", "success"); } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } } async showFavoriteGroupsModal() { const fm = window.favoritesManager; if (!fm) return; try { await fm.ensureInit(); await fm.listGroups(); const groups = (fm.groups || []).slice().sort((a, b) => { const ao = Number(a.sort_order || 0); const bo = Number(b.sort_order || 0); if (ao !== bo) return ao - bo; return String(a.name || "").localeCompare(String(b.name || ""), "fr"); }); const rows = groups.length === 0 ? `

Aucun groupe

` : groups .map( (g) => `
${this.escapeHtml(g.name)}
Ordre: ${Number(g.sort_order || 0)}
`, ) .join(""); this.showModal( "Groupes de favoris", `

${groups.length} groupe(s)

${rows}
`, ); } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } } showAddFavoriteGroupModal() { const fm = window.favoritesManager; if (!fm) return; this.showModal( "Ajouter un groupe de favoris", `
${this.renderFavoriteGroupColorPicker("")}
${this.renderFavoriteGroupIconPicker("")}
`, ); } async createFavoriteGroup(event) { event.preventDefault(); const fm = window.favoritesManager; if (!fm) return; const name = document.getElementById("fav-group-name")?.value?.trim() || ""; const sortOrder = Number(document.getElementById("fav-group-order")?.value || 0); const color = document.getElementById("fav-group-color-text")?.value?.trim() || null; const iconKey = document.getElementById("fav-group-icon-key")?.value?.trim() || null; try { await fm.createGroup({ name, sort_order: sortOrder, color, icon_key: iconKey }); this.closeModal(); this.showNotification("Groupe créé", "success"); this.renderFavoriteContainersWidget(); this.showFavoriteGroupsModal(); } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } } async showEditFavoriteGroupModal(groupId) { const fm = window.favoritesManager; if (!fm) return; await fm.ensureInit(); await fm.listGroups(); const g = (fm.groups || []).find((x) => Number(x.id) === Number(groupId)); if (!g) { this.showNotification("Groupe introuvable", "error"); return; } this.showModal( "Modifier le groupe", `
${this.renderFavoriteGroupColorPicker(this.escapeHtml(g.color || ""))}
${this.renderFavoriteGroupIconPicker(this.escapeHtml(g.icon_key || ""))}
`, ); } async updateFavoriteGroup(event, groupId) { event.preventDefault(); const fm = window.favoritesManager; if (!fm) return; const name = document.getElementById("fav-group-name")?.value?.trim() || ""; const sortOrder = Number(document.getElementById("fav-group-order")?.value || 0); const color = document.getElementById("fav-group-color-text")?.value?.trim() || null; const iconKey = document.getElementById("fav-group-icon-key")?.value?.trim() || null; try { await fm.updateGroup(Number(groupId), { name, sort_order: sortOrder, color, icon_key: iconKey }); this.closeModal(); this.showNotification("Groupe mis à jour", "success"); this.renderFavoriteContainersWidget(); this.showFavoriteGroupsModal(); } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } } async confirmDeleteFavoriteGroup(groupId) { const fm = window.favoritesManager; if (!fm) return; await fm.ensureInit(); await fm.listGroups(); const g = (fm.groups || []).find((x) => Number(x.id) === Number(groupId)); if (!g) { this.showNotification("Groupe introuvable", "error"); return; } this.showModal( "Confirmer la suppression", `

Supprimer le groupe

${this.escapeHtml(g.name)}

Les favoris seront déplacés en "Non classé".

`, ); } async deleteFavoriteGroup(groupId) { const fm = window.favoritesManager; if (!fm) return; try { await fm.deleteGroup(Number(groupId)); this.closeModal(); this.showNotification("Groupe supprimé", "success"); this.renderFavoriteContainersWidget(); this.showFavoriteGroupsModal(); } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } } /** * Expand all favorite groups */ expandAllFavorites() { const details = document.querySelectorAll("#dashboard-favorites details"); details.forEach((detail) => (detail.open = true)); } /** * Collapse all favorite groups */ collapseAllFavorites() { const details = document.querySelectorAll("#dashboard-favorites details"); details.forEach((detail) => (detail.open = false)); } /** * Clear favorites search */ clearFavoritesSearch() { const searchInput = document.getElementById("dashboard-favorites-search"); if (searchInput) { searchInput.value = ""; this.renderFavoriteContainersWidget(); } } /** * Open container drawer from favorites widget */ async openContainerDrawerFromFavorites(hostId, containerId) { const wantedHostId = String(hostId); const wantedContainerId = String(containerId); const tryOpen = async () => { if (!window.containersPage || typeof window.containersPage.openDrawer !== "function") return false; if (typeof window.containersPage.ensureInit === "function") { await window.containersPage.ensureInit(); } await window.containersPage.openDrawer(wantedHostId, wantedContainerId); return true; }; try { if (await tryOpen()) return; this.showNotification("Chargement du panneau...", "info"); if (typeof navigateTo === "function") { navigateTo("docker-containers"); } const maxAttempts = 20; for (let i = 0; i < maxAttempts; i++) { await new Promise((resolve) => setTimeout(resolve, 150)); if (await tryOpen()) return; } } catch (e) { this.showNotification(`Erreur drawer: ${e.message}`, "error"); } } /** * Charger plus d'historique dans le widget */ async loadMoreAdhocHistory() { try { // D'abord augmenter le nombre d'éléments affichés this.adhocWidgetOffset += 5; // Si on a déjà assez de logs chargés pour l'affichage, pas besoin de re-fetch. const desiredCount = this.adhocWidgetLimit + this.adhocWidgetOffset; const loadedCount = (this.adhocWidgetLogs || []).length; if (loadedCount < desiredCount && this.adhocWidgetHasMore) { const nextOffset = loadedCount; const nextData = await this.apiCall(`/api/tasks/logs?source_type=adhoc&limit=200&offset=${nextOffset}`); const nextLogs = nextData.logs || []; this.adhocWidgetLogs = [...(this.adhocWidgetLogs || []), ...nextLogs]; this.adhocWidgetTotalCount = Number(nextData.total_count || this.adhocWidgetTotalCount || this.adhocWidgetLogs.length || 0); this.adhocWidgetHasMore = Boolean(nextData.has_more); } this.renderAdhocWidget(); } catch (error) { console.error("Erreur chargement exécutions ad-hoc:", error); this.showNotification("Erreur chargement historique Ad-Hoc", "error"); } } /** * Formater une date en "il y a X minutes/heures/jours" */ formatTimeAgo(date) { const now = new Date(); const diffMs = now - date; const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); if (diffSec < 60) return "À l'instant"; if (diffMin < 60) return `Il y a ${diffMin}min`; if (diffHour < 24) return `Il y a ${diffHour}h`; if (diffDay < 7) return `Il y a ${diffDay}j`; return date.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" }); } /** * Rejouer une commande ad-hoc depuis l'historique */ replayAdhocCommand(commandId) { const cmd = this.adhocHistory.find((c) => c.id === commandId); if (!cmd) { this.showNotification("Commande non trouvée", "error"); return; } // Ouvrir la console avec la commande pré-remplie this.showAdHocConsole(); // Attendre que le modal soit rendu puis remplir les champs setTimeout(() => { this.loadHistoryCommand(cmd.command, cmd.target, cmd.module || "shell", cmd.become || false); }, 100); } /** * Afficher les détails d'une exécution ad-hoc */ showAdhocExecutionDetail(commandId) { const cmd = this.adhocHistory.find((c) => c.id === commandId); if (!cmd) { this.showNotification("Commande non trouvée", "error"); return; } const isSuccess = cmd.return_code === 0; const statusColor = isSuccess ? "text-green-400" : "text-red-400"; const statusBg = isSuccess ? "bg-green-900/30" : "bg-red-900/30"; const statusText = isSuccess ? "SUCCESS" : "FAILED"; // Parser les résultats par hôte si disponibles let hostsResults = []; let okCount = 0, changedCount = 0, failedCount = 0; if (cmd.stdout) { // Essayer de parser les résultats Ansible const lines = cmd.stdout.split("\n"); lines.forEach((line) => { const match = line.match(/^(\S+)\s*\|\s*(SUCCESS|CHANGED|FAILED|UNREACHABLE)/i); if (match) { const status = match[2].toUpperCase(); hostsResults.push({ host: match[1], status: status, line: line, }); if (status === "SUCCESS") okCount++; else if (status === "CHANGED") changedCount++; else failedCount++; } }); } // Si pas de résultats parsés, utiliser les infos de base if (hostsResults.length === 0 && cmd.hosts_count) { okCount = isSuccess ? cmd.hosts_count : 0; failedCount = isSuccess ? 0 : cmd.hosts_count; } const totalHosts = okCount + changedCount + failedCount || cmd.hosts_count || 1; const successRate = totalHosts > 0 ? Math.round(((okCount + changedCount) / totalHosts) * 100) : 0; // Formater la date const date = cmd.executed_at ? new Date(cmd.executed_at) : new Date(); const dateStr = date.toLocaleDateString("fr-FR", { year: "numeric", month: "2-digit", day: "2-digit" }); // Trouver la catégorie const category = this.adhocCategories.find((c) => c.name === cmd.category); const catColor = category?.color || "#7c3aed"; this.showModal( `Log: Ad-hoc: ${cmd.command?.split(" ")[0] || "commande"}`, `
Commande Ad-Hoc

Ad-hoc: ${this.escapeHtml(cmd.command?.split(" ")[0] || "commande")}

${totalHosts} hôte(s) Cible: ${this.escapeHtml(cmd.target || "all")} ${cmd.duration ? cmd.duration.toFixed(2) + "s" : "N/A"}
${statusText}
${dateStr}
# Code: ${cmd.return_code}
OK
${okCount}
CHANGED
${changedCount}
FAILED
${failedCount}
SUCCESS RATE
${successRate}%
Commande exécutée
${this.escapeHtml(cmd.command || "N/A")}
Module: ${cmd.module || "shell"} ${cmd.become ? 'Sudo: Oui' : ""} ${this.escapeHtml(cmd.category || "default")}
${hostsResults.length > 0 ? `
État des hôtes
${hostsResults .map((hr) => { const hrColor = hr.status === "SUCCESS" ? "border-green-700/50 bg-green-900/20" : hr.status === "CHANGED" ? "border-yellow-700/50 bg-yellow-900/20" : "border-red-700/50 bg-red-900/20"; const hrTextColor = hr.status === "SUCCESS" ? "text-green-400" : hr.status === "CHANGED" ? "text-yellow-400" : "text-red-400"; return `
${this.escapeHtml(hr.host)} ${hr.status}
${this.escapeHtml(hr.line.substring(0, 60))}...
`; }) .join("")}
` : "" } ${cmd.stdout ? `
Sortie standard
${this.escapeHtml(cmd.stdout)}
` : "" } ${cmd.stderr ? `
Erreurs/Avertissements
${this.escapeHtml(cmd.stderr)}
` : "" }
`, ); } /** * Filtrer les hôtes dans le détail d'exécution */ filterAdhocDetailHosts(filter) { // Mettre à jour les boutons document.querySelectorAll(".adhoc-detail-filter").forEach((btn) => { if (btn.dataset.filter === filter) { btn.classList.add("bg-gray-700", "text-white"); btn.classList.remove("text-gray-400"); } else { btn.classList.remove("bg-gray-700", "text-white"); btn.classList.add("text-gray-400"); } }); // Filtrer les résultats document.querySelectorAll(".adhoc-host-result").forEach((el) => { const status = el.dataset.status; if (filter === "all" || status === filter || (filter === "ok" && status === "success")) { el.classList.remove("hidden"); } else { el.classList.add("hidden"); } }); } // ===== MÉTHODES DU PLANIFICATEUR (SCHEDULES) ===== renderSchedules() { const listContainer = document.getElementById("schedules-list"); const emptyState = document.getElementById("schedules-empty"); if (!listContainer) return; // Filtrer les schedules let filteredSchedules = [...this.schedules]; if (this.currentScheduleFilter === "active") { filteredSchedules = filteredSchedules.filter((s) => s.enabled); } else if (this.currentScheduleFilter === "paused") { filteredSchedules = filteredSchedules.filter((s) => !s.enabled); } if (this.scheduleSearchQuery) { const query = this.scheduleSearchQuery.toLowerCase(); filteredSchedules = filteredSchedules.filter((s) => s.name.toLowerCase().includes(query) || s.playbook.toLowerCase().includes(query) || s.target.toLowerCase().includes(query)); } // Mettre à jour les stats this.updateSchedulesStats(); // Afficher l'état vide ou la liste if (this.schedules.length === 0) { listContainer.innerHTML = ""; emptyState?.classList.remove("hidden"); return; } emptyState?.classList.add("hidden"); if (filteredSchedules.length === 0) { listContainer.innerHTML = `

Zero Matches

Adjust filter parameters to broaden matrix scan

`; return; } listContainer.innerHTML = filteredSchedules.map((schedule) => this.renderScheduleCard(schedule)).join(""); // Mettre à jour les prochaines exécutions this.renderUpcomingExecutions(); } renderScheduleCard(schedule) { const statusClass = schedule.enabled ? "active" : "paused"; const statusChipClass = schedule.enabled ? "active" : "paused"; const statusText = schedule.enabled ? "Actif" : "En pause"; // Formater la prochaine exécution let nextRunText = "--"; if (schedule.next_run_at) { const nextRun = new Date(schedule.next_run_at); nextRunText = this.formatRelativeTime(nextRun); } // Formater la dernière exécution let lastRunHtml = ""; if (schedule.last_run_at) { const lastStatusIcon = schedule.last_status === "success" ? "✅" : schedule.last_status === "failed" ? "❌" : schedule.last_status === "running" ? "🔄" : ""; const lastRunDate = new Date(schedule.last_run_at); lastRunHtml = `| Dernier: ${lastStatusIcon} ${this.formatRelativeTime(lastRunDate)}`; } // Formater la récurrence let recurrenceText = "Exécution unique"; if (schedule.schedule_type === "recurring" && schedule.recurrence) { const rec = schedule.recurrence; if (rec.type === "daily") { recurrenceText = `Tous les jours à ${rec.time}`; } else if (rec.type === "weekly") { const days = (rec.days || []).map((d) => ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"][d - 1]).join(", "); recurrenceText = `Chaque ${days} à ${rec.time}`; } else if (rec.type === "monthly") { recurrenceText = `Le ${rec.day_of_month || 1} de chaque mois à ${rec.time}`; } else if (rec.type === "custom") { recurrenceText = `Cron: ${rec.cron_expression}`; } } // Tags const tagsHtml = (schedule.tags || []).map((tag) => `${tag}`).join(""); const statusLabel = schedule.enabled ? 'ACTIVE' : 'PAUSED'; return `

${this.escapeHtml(schedule.name)}

${statusLabel}
${tagsHtml}
${this.escapeHtml(schedule.description || "No description provided")}
${this.escapeHtml(schedule.playbook)} ${this.escapeHtml(schedule.target)} ${recurrenceText}
NEXT: ${nextRunText} ${lastRunHtml ? `LAST: ${lastRunHtml}` : ""}
${schedule.enabled ? ` ` : ` ` }
`; } updateSchedulesStats() { const total = this.schedules.length; const active = this.schedules.filter((s) => s.enabled).length; const paused = total - active; const failures = this.schedulesStats.failures_24h || 0; const statsContainer = document.querySelector(".schedules-stats-summary"); if (statsContainer) { statsContainer.innerHTML = `
Matrix Total ${total} TASKS
Active Link ${active} ENGAGED
Standby Mode ${paused} SUSPENDED
Fault Events ${failures} /24H
`; } // Keep legacy IDs for compatibility with other view updates const activeCountEl = document.getElementById("schedules-active-count"); if (activeCountEl) activeCountEl.textContent = active; const pausedCountEl = document.getElementById("schedules-paused-count"); if (pausedCountEl) pausedCountEl.textContent = paused; const failuresEl = document.getElementById("schedules-failures-24h"); if (failuresEl) failuresEl.textContent = failures; // Dashboard widget const dashboardActiveEl = document.getElementById("dashboard-schedules-active"); if (dashboardActiveEl) dashboardActiveEl.textContent = active; const dashboardFailuresEl = document.getElementById("dashboard-schedules-failures"); if (dashboardFailuresEl) dashboardFailuresEl.textContent = failures; // Prochaine exécution const activeSchedules = this.schedules.filter((s) => s.enabled && s.next_run_at); if (activeSchedules.length > 0) { activeSchedules.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at)); const nextRun = new Date(activeSchedules[0].next_run_at); const now = new Date(); const diffMs = nextRun - now; const nextRunEl = document.getElementById("schedules-next-run"); const dashboardNextEl = document.getElementById("dashboard-schedules-next"); if (diffMs > 0) { const hours = Math.floor(diffMs / (1000 * 60 * 60)); const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); let nextText; if (hours > 0) { nextText = `${hours}h${mins}m`; } else { nextText = `${mins}min`; } if (nextRunEl) nextRunEl.textContent = nextText; if (dashboardNextEl) dashboardNextEl.textContent = nextText; } else { if (nextRunEl) nextRunEl.textContent = "Imminente"; if (dashboardNextEl) dashboardNextEl.textContent = "Imminent"; } } else { const nextRunEl = document.getElementById("schedules-next-run"); const dashboardNextEl = document.getElementById("dashboard-schedules-next"); if (nextRunEl) nextRunEl.textContent = "--:--"; if (dashboardNextEl) dashboardNextEl.textContent = "--"; } // Update dashboard upcoming schedules this.updateDashboardUpcomingSchedules(); } updateDashboardUpcomingSchedules() { const container = document.getElementById("dashboard-upcoming-schedules"); if (!container) return; const upcoming = this.schedules .filter((s) => s.enabled && s.next_run_at) .sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at)) .slice(0, 3); if (upcoming.length === 0) { container.innerHTML = '

Aucun schedule actif

'; return; } container.innerHTML = upcoming .map((s) => { const nextRun = new Date(s.next_run_at); return `
${s.name}
${nextRun.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
`; }) .join(""); } renderUpcomingExecutions() { const container = document.getElementById("schedules-upcoming"); if (!container) return; const activeSchedules = this.schedules .filter((s) => s.enabled && s.next_run_at) .sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at)) .slice(0, 5); if (activeSchedules.length === 0) { container.innerHTML = '

Aucune exécution planifiée

'; return; } container.innerHTML = activeSchedules .map((s) => { const nextRun = new Date(s.next_run_at); return `
${s.name}
${s.playbook} → ${s.target}
${nextRun.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
${nextRun.toLocaleDateString("fr-FR", { weekday: "short", day: "numeric", month: "short" })}
`; }) .join(""); } formatRelativeTime(date) { const now = new Date(); const diffMs = date - now; const absDiffMs = Math.abs(diffMs); const isPast = diffMs < 0; const mins = Math.floor(absDiffMs / (1000 * 60)); const hours = Math.floor(absDiffMs / (1000 * 60 * 60)); const days = Math.floor(absDiffMs / (1000 * 60 * 60 * 24)); if (days > 0) { return isPast ? `il y a ${days}j` : `dans ${days}j`; } else if (hours > 0) { return isPast ? `il y a ${hours}h` : `dans ${hours}h`; } else if (mins > 0) { return isPast ? `il y a ${mins}min` : `dans ${mins}min`; } else { return isPast ? "à l'instant" : "imminent"; } } filterSchedules(filter) { this.currentScheduleFilter = filter; // Mettre à jour les boutons document.querySelectorAll(".schedule-filter-btn").forEach((btn) => { btn.classList.remove("active", "bg-purple-600", "text-white"); btn.classList.add("bg-gray-700", "text-gray-300"); if (btn.dataset.filter === filter) { btn.classList.add("active", "bg-purple-600", "text-white"); btn.classList.remove("bg-gray-700", "text-gray-300"); } }); this.renderSchedules(); } searchSchedules(query) { this.scheduleSearchQuery = query; this.renderSchedules(); } toggleScheduleView(view) { const listView = document.getElementById("schedules-list-view"); const calendarView = document.getElementById("schedules-calendar-view"); if (view === "calendar") { listView?.classList.add("hidden"); calendarView?.classList.remove("hidden"); this.renderScheduleCalendar(); } else { listView?.classList.remove("hidden"); calendarView?.classList.add("hidden"); } } async refreshSchedules() { try { const [schedulesData, statsData] = await Promise.all([this.apiCall("/api/schedules"), this.apiCall("/api/schedules/stats")]); this.schedules = schedulesData.schedules || []; this.schedulesStats = statsData.stats || {}; this.schedulesUpcoming = statsData.upcoming || []; this.renderSchedules(); this.showNotification("Schedules rafraîchis", "success"); } catch (error) { this.showNotification("Erreur lors du rafraîchissement", "error"); } } async refreshSchedulesStats() { try { const statsData = await this.apiCall("/api/schedules/stats"); this.schedulesStats = statsData.stats || {}; this.schedulesUpcoming = statsData.upcoming || []; this.updateSchedulesStats(); } catch (error) { console.error("Erreur rafraîchissement stats schedules:", error); } } // ===== ACTIONS SCHEDULES ===== async runScheduleNow(scheduleId) { if (!confirm("Exécuter ce schedule immédiatement ?")) return; try { this.showLoading(); await this.apiCall(`/api/schedules/${scheduleId}/run`, { method: "POST" }); this.showNotification("Schedule lancé", "success"); } catch (error) { this.showNotification("Erreur lors du lancement", "error"); } finally { this.hideLoading(); } } async pauseSchedule(scheduleId) { try { const result = await this.apiCall(`/api/schedules/${scheduleId}/pause`, { method: "POST" }); const schedule = this.schedules.find((s) => s.id === scheduleId); if (schedule) schedule.enabled = false; this.renderSchedules(); this.showNotification(`Schedule mis en pause`, "success"); } catch (error) { this.showNotification("Erreur lors de la mise en pause", "error"); } } async resumeSchedule(scheduleId) { try { const result = await this.apiCall(`/api/schedules/${scheduleId}/resume`, { method: "POST" }); const schedule = this.schedules.find((s) => s.id === scheduleId); if (schedule) schedule.enabled = true; this.renderSchedules(); this.showNotification(`Schedule repris`, "success"); } catch (error) { this.showNotification("Erreur lors de la reprise", "error"); } } async deleteSchedule(scheduleId) { const schedule = this.schedules.find((s) => s.id === scheduleId); if (!schedule) return; if (!confirm(`Supprimer le schedule "${schedule.name}" ?`)) return; try { await this.apiCall(`/api/schedules/${scheduleId}`, { method: "DELETE" }); this.schedules = this.schedules.filter((s) => s.id !== scheduleId); this.renderSchedules(); this.showNotification(`Schedule supprimé`, "success"); } catch (error) { this.showNotification("Erreur lors de la suppression", "error"); } } // ===== MODAL CRÉATION/ÉDITION SCHEDULE ===== async showCreateScheduleModal(prefilledPlaybook = null) { this.editingScheduleId = null; this.scheduleModalStep = 1; // S'assurer que les playbooks sont chargés if (!this.playbooks || this.playbooks.length === 0) { try { const playbooksData = await this.apiCall("/api/ansible/playbooks"); this.playbooks = playbooksData.playbooks || []; } catch (error) { console.error("Erreur chargement playbooks:", error); } } const content = this.getScheduleModalContent(null, prefilledPlaybook); this.showModal("Nouveau Schedule", content, "schedule-modal"); } async showEditScheduleModal(scheduleId) { const schedule = this.schedules.find((s) => s.id === scheduleId); if (!schedule) return; this.editingScheduleId = scheduleId; this.scheduleModalStep = 1; // S'assurer que les playbooks sont chargés if (!this.playbooks || this.playbooks.length === 0) { try { const playbooksData = await this.apiCall("/api/ansible/playbooks"); this.playbooks = playbooksData.playbooks || []; } catch (error) { console.error("Erreur chargement playbooks:", error); } } const content = this.getScheduleModalContent(schedule); this.showModal(`Modifier: ${schedule.name}`, content, "schedule-modal"); } getScheduleModalContent(schedule = null, prefilledPlaybook = null) { const isEdit = !!schedule; const s = schedule || {}; // Options de playbooks const playbookOptions = this.playbooks.map((p) => ``).join(""); // Options de groupes const groupOptions = this.ansibleGroups.map((g) => ``).join(""); // Options d'hôtes const hostOptions = this.ansibleHosts.map((h) => ``).join(""); // Récurrence const rec = s.recurrence || {}; const daysChecked = rec.days || [1]; return `
1
2
3
4

Informations de base

${["Backup", "Maintenance", "Monitoring", "Production", "Test"] .map( (tag) => ` `, ) .join("")}

Quoi exécuter ?

Quand exécuter ?

${["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"] .map( (day, i) => ` `, ) .join("")}

Notifications

`; } async scheduleModalNextStep() { if (this.scheduleModalStep < 4) { this.scheduleModalStep++; this.updateScheduleModalStep(); // Si on arrive à l'étape 4 (Notifications), vérifier si NTFY est activé if (this.scheduleModalStep === 4) { await this.checkNtfyStatus(); } } } async checkNtfyStatus() { try { const config = await this.apiCall("/api/notifications/config"); const warningEl = document.getElementById("ntfy-disabled-warning"); if (warningEl) { warningEl.classList.toggle("hidden", config.enabled !== false); } } catch (error) { console.error("Erreur vérification statut NTFY:", error); } } scheduleModalPrevStep() { if (this.scheduleModalStep > 1) { this.scheduleModalStep--; this.updateScheduleModalStep(); } } updateScheduleModalStep() { // Mettre à jour les indicateurs document.querySelectorAll(".schedule-step-dot").forEach((dot) => { const step = parseInt(dot.dataset.step); dot.classList.remove("active", "completed"); if (step < this.scheduleModalStep) { dot.classList.add("completed"); } else if (step === this.scheduleModalStep) { dot.classList.add("active"); } }); document.querySelectorAll(".schedule-step-connector").forEach((conn, i) => { conn.classList.toggle("active", i < this.scheduleModalStep - 1); }); // Afficher l'étape actuelle document.querySelectorAll(".schedule-modal-step").forEach((step) => { step.classList.remove("active"); if (parseInt(step.dataset.step) === this.scheduleModalStep) { step.classList.add("active"); } }); } toggleScheduleTargetType(type) { document.getElementById("schedule-group-select")?.classList.toggle("hidden", type === "host"); document.getElementById("schedule-host-select")?.classList.toggle("hidden", type === "group"); } toggleScheduleType(type) { document.getElementById("schedule-recurring-options")?.classList.toggle("hidden", type === "once"); document.getElementById("schedule-once-options")?.classList.toggle("hidden", type === "recurring"); } updateRecurrenceOptions() { const type = document.getElementById("schedule-recurrence-type")?.value; document.getElementById("recurrence-time")?.classList.toggle("hidden", type === "custom"); document.getElementById("recurrence-weekly-days")?.classList.toggle("hidden", type !== "weekly"); document.getElementById("recurrence-monthly-day")?.classList.toggle("hidden", type !== "monthly"); document.getElementById("recurrence-cron")?.classList.toggle("hidden", type !== "custom"); } async validateCronExpression(expression) { const container = document.getElementById("cron-validation"); if (!container || !expression.trim()) { if (container) container.innerHTML = ""; return; } try { const result = await this.apiCall(`/api/schedules/validate-cron?expression=${encodeURIComponent(expression)}`); if (result.valid) { container.innerHTML = `
Expression valide
Prochaines: ${result.next_runs ?.slice(0, 3) .map((r) => new Date(r).toLocaleString("fr-FR")) .join(", ")}
`; } else { container.innerHTML = `
${result.error}
`; } } catch (error) { container.innerHTML = `
Erreur de validation
`; } } async saveSchedule() { const name = document.getElementById("schedule-name")?.value.trim(); const description = document.getElementById("schedule-description")?.value.trim(); const playbook = document.getElementById("schedule-playbook")?.value; const targetType = document.querySelector('input[name="schedule-target-type"]:checked')?.value || "group"; const targetGroup = document.getElementById("schedule-target-group")?.value; const targetHost = document.getElementById("schedule-target-host")?.value; const timeout = parseInt(document.getElementById("schedule-timeout")?.value) || 3600; const scheduleType = document.querySelector('input[name="schedule-type"]:checked')?.value || "recurring"; const enabled = document.getElementById("schedule-enabled")?.checked ?? true; const notificationType = document.querySelector('input[name="schedule-notification-type"]:checked')?.value || "all"; // Validation if (!name || name.length < 3) { this.showNotification("Le nom doit faire au moins 3 caractères", "error"); return; } if (!playbook) { this.showNotification("Veuillez sélectionner un playbook", "error"); return; } // Construire les tags const tags = Array.from(document.querySelectorAll(".schedule-tag-checkbox:checked")).map((cb) => cb.value); // Construire la récurrence let recurrence = null; let startAt = null; if (scheduleType === "recurring") { const recType = document.getElementById("schedule-recurrence-type")?.value || "daily"; const time = document.getElementById("schedule-time")?.value || "02:00"; recurrence = { type: recType, time }; if (recType === "weekly") { recurrence.days = Array.from(document.querySelectorAll(".schedule-day-checkbox:checked")).map((cb) => parseInt(cb.value)); if (recurrence.days.length === 0) recurrence.days = [1]; } else if (recType === "monthly") { recurrence.day_of_month = parseInt(document.getElementById("schedule-day-of-month")?.value) || 1; } else if (recType === "custom") { recurrence.cron_expression = document.getElementById("schedule-cron")?.value; if (!recurrence.cron_expression) { this.showNotification("Veuillez entrer une expression cron", "error"); return; } } } else { const startAtValue = document.getElementById("schedule-start-at")?.value; if (startAtValue) { startAt = new Date(startAtValue).toISOString(); } } const payload = { name, description: description || null, playbook, target_type: targetType, target: targetType === "host" ? targetHost : targetGroup, timeout, schedule_type: scheduleType, recurrence, start_at: startAt, enabled, tags, notification_type: notificationType, }; try { this.showLoading(); if (this.editingScheduleId) { await this.apiCall(`/api/schedules/${this.editingScheduleId}`, { method: "PUT", body: JSON.stringify(payload) }); } else { await this.apiCall("/api/schedules", { method: "POST", body: JSON.stringify(payload) }); } this.closeModal(); await this.refreshSchedules(); } catch (error) { this.showNotification(error.message || "Erreur lors de la sauvegarde", "error"); } finally { this.hideLoading(); } } async showScheduleHistory(scheduleId) { const schedule = this.schedules.find((s) => s.id === scheduleId); if (!schedule) return; try { const result = await this.apiCall(`/api/schedules/${scheduleId}/runs?limit=50`); const runs = result.runs || []; let content; if (runs.length === 0) { content = `

Aucune exécution enregistrée

Le schedule n'a pas encore été exécuté.

`; } else { content = `
${runs.length} exécution(s) - Taux de succès: ${schedule.run_count > 0 ? Math.round((schedule.success_count / schedule.run_count) * 100) : 0}%
${runs .map((run) => { const startedAt = new Date(run.started_at); const statusClass = run.status === "success" ? "success" : run.status === "failed" ? "failed" : run.status === "running" ? "running" : "scheduled"; const statusIcon = run.status === "success" ? "check-circle" : run.status === "failed" ? "times-circle" : run.status === "running" ? "spinner fa-spin" : "clock"; return `
${run.status}
${startedAt.toLocaleString("fr-FR")}
${run.duration_seconds ? `
Durée: ${run.duration_seconds.toFixed(1)}s
` : ""}
${run.hosts_impacted > 0 ? `${run.hosts_impacted} hôte(s)` : ""} ${run.task_id ? `Voir tâche` : ""}
`; }) .join("")}
`; } this.showModal(`Historique: ${schedule.name}`, content); } catch (error) { this.showNotification("Erreur lors du chargement de l'historique", "error"); } } // ===== CALENDRIER DES SCHEDULES ===== renderScheduleCalendar() { const grid = document.getElementById("schedule-calendar-grid"); const titleEl = document.getElementById("schedule-calendar-title"); if (!grid || !titleEl) return; const year = this.scheduleCalendarMonth.getFullYear(); const month = this.scheduleCalendarMonth.getMonth(); // Titre const monthNames = ["Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"]; titleEl.textContent = `${monthNames[month]} ${year}`; // Premier jour du mois const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); // Ajuster pour commencer par Lundi (0 = Dimanche dans JS) let startDay = firstDay.getDay() - 1; if (startDay < 0) startDay = 6; // Générer les jours const days = []; const today = new Date(); today.setHours(0, 0, 0, 0); // Jours du mois précédent const prevMonth = new Date(year, month, 0); for (let i = startDay - 1; i >= 0; i--) { days.push({ date: new Date(year, month - 1, prevMonth.getDate() - i), otherMonth: true, }); } // Jours du mois actuel for (let d = 1; d <= lastDay.getDate(); d++) { const date = new Date(year, month, d); days.push({ date, otherMonth: false, isToday: date.getTime() === today.getTime(), }); } // Jours du mois suivant const remainingDays = 42 - days.length; for (let d = 1; d <= remainingDays; d++) { days.push({ date: new Date(year, month + 1, d), otherMonth: true, }); } // Générer le HTML grid.innerHTML = days .map((day) => { const dateStr = day.date.toISOString().split("T")[0]; const classes = ["schedule-calendar-day"]; if (day.otherMonth) classes.push("other-month"); if (day.isToday) classes.push("today"); // Événements pour ce jour (simplifiés - prochaines exécutions) const events = this.schedulesUpcoming.filter((s) => { if (!s.next_run_at) return false; const runDate = new Date(s.next_run_at).toISOString().split("T")[0]; return runDate === dateStr; }); return `
${day.date.getDate()}
${events .slice(0, 2) .map( (e) => `
${e.schedule_name}
`, ) .join("")} ${events.length > 2 ? `
+${events.length - 2}
` : ""}
`; }) .join(""); } prevCalendarMonth() { this.scheduleCalendarMonth.setMonth(this.scheduleCalendarMonth.getMonth() - 1); this.renderScheduleCalendar(); } nextCalendarMonth() { this.scheduleCalendarMonth.setMonth(this.scheduleCalendarMonth.getMonth() + 1); this.renderScheduleCalendar(); } // ===== FIN DES MÉTHODES PLANIFICATEUR ===== closeModal() { const modal = document.getElementById("modal"); anime({ targets: "#modal .glass-card", scale: [1, 0.8], opacity: [1, 0], duration: 200, easing: "easeInExpo", complete: () => { modal.classList.add("hidden"); }, }); } showLoading() { document.getElementById("loading-overlay").classList.remove("hidden"); } hideLoading() { document.getElementById("loading-overlay").classList.add("hidden"); } showNotification(message, type = "info") { // Persister en alerte (fire-and-forget) try { const msgStr = String(message || ""); const lower = msgStr.toLowerCase(); let category = "notification"; if (lower.includes("métrique") || lower.includes("metrics")) { category = "metric"; } else if (lower.includes("playbook")) { category = "playbook"; } else if (lower.includes("schedule")) { category = "schedule"; } else if (lower.includes("bootstrap")) { category = "bootstrap"; } else if (lower.includes("task") || lower.includes("tâche")) { category = "task"; } const title = type === "success" ? "Succès" : type === "warning" ? "Avertissement" : type === "error" ? "Erreur" : "Info"; // 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 } const notification = document.createElement("div"); notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${type === "success" ? "bg-green-600" : type === "warning" ? "bg-yellow-600" : type === "error" ? "bg-red-600" : "bg-blue-600"} text-white`; notification.innerHTML = `
${message}
`; document.body.appendChild(notification); // Animate in anime({ targets: notification, translateX: [300, 0], opacity: [0, 1], duration: 300, easing: "easeOutExpo", }); // Remove after 3 seconds setTimeout(() => { anime({ targets: notification, translateX: [0, 300], opacity: [1, 0], duration: 300, easing: "easeInExpo", complete: () => { notification.remove(); }, }); }, 3000); } // ===== ALERTES ===== updateAlertsBadge() { const badge = document.getElementById("alerts-badge"); if (!badge) return; const count = Number(this.alertsUnread || 0); if (count > 0) { badge.textContent = count > 99 ? "99+" : String(count); badge.classList.remove("hidden"); } else { badge.classList.add("hidden"); } } async refreshAlerts() { try { const data = await this.apiCall("/api/alerts?limit=200&offset=0"); // L'API retourne {alerts: [...], count: N} this.alerts = Array.isArray(data) ? data : data.alerts || []; this.renderAlerts(); } catch (error) { console.error("Erreur chargement alertes:", error); } } async refreshAlertsCount() { try { const data = await this.apiCall("/api/alerts/unread-count"); this.alertsUnread = data.unread || 0; this.updateAlertsBadge(); } catch (error) { console.error("Erreur chargement compteur alertes:", error); } } handleAlertCreated(alert) { if (!alert) return; this.alerts = [alert, ...(this.alerts || [])].slice(0, 200); this.renderAlerts(); this.refreshAlertsCount(); } async markAlertRead(alertId) { try { await this.apiCall(`/api/alerts/${alertId}/read`, { method: "POST" }); const idx = (this.alerts || []).findIndex((a) => a.id === alertId); if (idx !== -1) { this.alerts[idx].read_at = new Date().toISOString(); } this.renderAlerts(); this.refreshAlertsCount(); } catch (error) { console.error("Erreur marquer alerte lue:", error); } } async markAllAlertsRead() { try { await this.apiCall("/api/alerts/mark-all-read", { method: "POST" }); (this.alerts || []).forEach((a) => { a.read_at = a.read_at || new Date().toISOString(); }); this.renderAlerts(); this.refreshAlertsCount(); } catch (error) { console.error("Erreur marquer toutes alertes lues:", error); } } renderAlerts() { const container = document.getElementById("alerts-container"); if (!container) return; const items = this.alerts || []; if (items.length === 0) { container.innerHTML = `

Aucune alerte pour le moment

`; return; } container.innerHTML = items .map((a) => { const created = a.created_at ? new Date(a.created_at).toLocaleString("fr-FR") : "--"; const isRead = Boolean(a.read_at || a.read); const pill = isRead ? 'Lu' : 'Non lu'; const cat = a.category || "notification"; const catBadge = `${this.escapeHtml(cat)}`; const title = a.title ? `
${this.escapeHtml(a.title)}
` : ""; const msg = `
${this.escapeHtml(a.message || "")}
`; const meta = `
${created}${catBadge}${a.source ? `[${this.escapeHtml(a.source)}]` : ""}${pill}
`; const actions = !isRead ? `` : ""; const border = isRead ? "border-gray-700/60" : "border-red-700/60"; const bg = isRead ? "bg-gray-800/40" : "bg-gray-800/60"; return `
${title} ${msg} ${meta}
${actions}
`; }) .join(""); } // ===================================================== // Terminal SSH - Web Terminal Feature // ===================================================== 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) { console.warn("Terminal feature check failed:", e); this.terminalFeatureAvailable = false; } return { available: false }; } async openTerminal(hostId, hostName, hostIp) { // Anti-double-click: reuse pending promise if (this.terminalOpeningPromise) { return this.terminalOpeningPromise; } // Check if terminal feature is available if (!this.terminalFeatureAvailable) { const status = await this.checkTerminalFeatureStatus(); if (!status.available) { this.showNotification("Terminal SSH non disponible: ttyd n'est pas installé", "error"); return; } // Store heartbeat interval from server config if (status.heartbeat_interval_seconds) { this.terminalHeartbeatIntervalMs = status.heartbeat_interval_seconds * 1000; } } // Show loading state this.showTerminalDrawer(hostName, hostIp, true); // Create promise to prevent double-click this.terminalOpeningPromise = (async () => { try { const response = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, { method: "POST", body: JSON.stringify({ mode: "embedded" }), }); // Check if this is a session limit error (429 with rich response) if (response.error === "SESSION_LIMIT") { this.closeTerminalDrawer(); this.showSessionLimitModal(response, hostId, hostName, hostIp); return; } this.terminalSession = response; // Update drawer with terminal iframe this.updateTerminalDrawer(response); // Start heartbeat this.startTerminalHeartbeat(); // Log if session was reused if (response.reused) { console.log("Terminal session reused:", response.session_id); } } catch (e) { console.error("Failed to open terminal:", e); // Check if error response contains session limit info if (e.response && e.response.error === "SESSION_LIMIT") { this.closeTerminalDrawer(); this.showSessionLimitModal(e.response, hostId, hostName, hostIp); } else { this.showNotification(`Erreur terminal: ${e.message}`, "error"); this.closeTerminalDrawer(); } } finally { this.terminalOpeningPromise = null; } })(); return this.terminalOpeningPromise; } async openTerminalPopout(hostId, hostName, hostIp) { // 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"); try { const session = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, { method: "POST", body: JSON.stringify({ mode: "popout" }), }); // Open in popup window const popupUrl = session.url; const popupFeatures = "width=900,height=600,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no"; const popup = window.open(popupUrl, `terminal_${session.session_id}`, popupFeatures); if (!popup || popup.closed) { // Popup blocked - open in new tab instead window.open(popupUrl, "_blank"); this.showNotification("Terminal ouvert dans un nouvel onglet (popup bloqué)", "warning"); } } catch (e) { console.error("Failed to open terminal popout:", e); this.showNotification(`Erreur terminal: ${e.message}`, "error"); } finally { this.terminalPopoutOpening = false; } } showTerminalDrawer(hostName, hostIp, loading = false) { this.terminalDrawerOpen = true; this.terminalHistoryPanelOpen = false; this.terminalHistoryPanelPinned = false; // Create drawer if it doesn't exist let drawer = document.getElementById("terminalDrawer"); if (!drawer) { drawer = document.createElement("div"); drawer.id = "terminalDrawer"; drawer.className = "terminal-drawer"; document.body.appendChild(drawer); } const loadingContent = loading ? `
Connexion à ${this.escapeHtml(hostName)}...
` : ""; drawer.innerHTML = `
${this.escapeHtml(hostName)} ${this.escapeHtml(hostIp)} Connexion...
${loadingContent}
`; // Animate in requestAnimationFrame(() => { drawer.classList.add("open"); }); // Add keyboard handlers (Escape to close, Ctrl+R for history) this._terminalEscHandler = (e) => { if (e.key === "Escape" && this.terminalDrawerOpen) { // If history panel is open, close it first if (this.terminalHistoryPanelOpen) { this.closeTerminalHistoryPanel(); e.preventDefault(); return; } this.closeTerminalDrawer(); } // Ctrl+R to open/focus history search if ((e.ctrlKey || e.metaKey) && e.key === "r" && this.terminalDrawerOpen) { e.preventDefault(); this.openTerminalHistoryPanel(); } }; document.addEventListener("keydown", this._terminalEscHandler); } updateTerminalDrawer(session) { const body = document.getElementById("terminalDrawerBody"); if (!body) return; const statusBadge = document.querySelector(".terminal-status-badge"); if (statusBadge) { statusBadge.textContent = "Connecté"; statusBadge.classList.remove("connecting"); statusBadge.classList.add("online"); } // Create iframe for terminal // Use embed mode so the connect page can hide its own header/pwa hint // and let the drawer UI be the single source of controls. const embeddedUrl = (() => { try { const url = new URL(session.url, window.location.origin); url.searchParams.set("embed", "1"); return url.toString(); } catch (e) { // Fallback for relative URLs return session.url + (session.url.includes("?") ? "&" : "?") + "embed=1"; } })(); body.innerHTML = ` `; // Start countdown timer this.startTerminalTimer(session.ttl_seconds); } onTerminalIframeLoad() { const iframe = document.getElementById("terminalIframe"); if (iframe) { iframe.focus(); } } startTerminalTimer(seconds) { const timerEl = document.getElementById("terminalTimer"); if (!timerEl) return; let remaining = seconds; const updateTimer = () => { if (remaining <= 0) { timerEl.textContent = "Session expirée"; timerEl.classList.add("expired"); return; } const mins = Math.floor(remaining / 60); const secs = remaining % 60; timerEl.textContent = `${mins}:${secs.toString().padStart(2, "0")}`; if (remaining < 60) { timerEl.classList.add("warning"); } remaining--; }; updateTimer(); this._terminalTimerInterval = setInterval(updateTimer, 1000); } async closeTerminalDrawer(options = {}) { const { closeSession = true } = options; const drawer = document.getElementById("terminalDrawer"); if (drawer) { drawer.classList.remove("open"); setTimeout(() => { drawer.remove(); }, 300); } // Clean up timer if (this._terminalTimerInterval) { clearInterval(this._terminalTimerInterval); this._terminalTimerInterval = null; } // Stop heartbeat this.stopTerminalHeartbeat(); // Remove escape handler if (this._terminalEscHandler) { document.removeEventListener("keydown", this._terminalEscHandler); this._terminalEscHandler = null; } // Close session on server (best effort) if (closeSession && this.terminalSession) { try { await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}`, { method: "DELETE", }); } catch (e) { console.warn("Failed to close terminal session:", e); } this.terminalSession = null; } this.terminalDrawerOpen = false; this.terminalHistoryPanelOpen = false; this.terminalHistoryPanelPinned = false; } // ===== TERMINAL CLEANUP HANDLERS ===== setupTerminalCleanupHandlers() { // Handle page unload (browser close, navigation away) window.addEventListener("beforeunload", () => { this.sendTerminalCloseBeacon(); }); // Handle page hide (mobile tab switch, etc.) window.addEventListener("pagehide", () => { this.sendTerminalCloseBeacon(); }); // Handle visibility change (tab hidden) document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { // Stop heartbeat when tab is hidden this.stopTerminalHeartbeat(); } else if (document.visibilityState === "visible" && this.terminalSession && this.terminalDrawerOpen) { // Resume heartbeat when tab becomes visible again this.startTerminalHeartbeat(); } }); } sendTerminalCloseBeacon() { if (!this.terminalSession) return; const sessionId = this.terminalSession.session_id; const url = `${this.apiBase}/api/terminal/sessions/${sessionId}/close-beacon`; // Use sendBeacon for reliable delivery during page unload if (navigator.sendBeacon) { const blob = new Blob([JSON.stringify({ reason: "client_close" })], { type: "application/json" }); navigator.sendBeacon(url, blob); console.log("Terminal close beacon sent via sendBeacon"); } else { // Fallback: synchronous XHR (blocking, but ensures delivery) try { const xhr = new XMLHttpRequest(); xhr.open("POST", url, false); // synchronous xhr.setRequestHeader("Content-Type", "application/json"); xhr.send(JSON.stringify({ reason: "client_close" })); console.log("Terminal close beacon sent via XHR"); } catch (e) { console.warn("Failed to send terminal close beacon:", e); } } // Clear session reference this.terminalSession = null; } // ===== TERMINAL HEARTBEAT ===== startTerminalHeartbeat() { this.stopTerminalHeartbeat(); // Clear any existing if (!this.terminalSession) return; this.terminalHeartbeatInterval = setInterval(async () => { if (!this.terminalSession || !this.terminalDrawerOpen) { this.stopTerminalHeartbeat(); return; } try { await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}/heartbeat?token=${encodeURIComponent(this.terminalSession.token)}`, { method: "POST", }); } catch (e) { console.warn("Terminal heartbeat failed:", e); if (e && (e.status === 404 || e.status === 403)) { this.stopTerminalHeartbeat(); this.terminalSession = null; return; } } }, this.terminalHeartbeatIntervalMs); console.log("Terminal heartbeat started"); } stopTerminalHeartbeat() { if (this.terminalHeartbeatInterval) { clearInterval(this.terminalHeartbeatInterval); this.terminalHeartbeatInterval = null; console.log("Terminal heartbeat stopped"); } } // ===== TERMINAL SESSION LIMIT MODAL ===== showSessionLimitModal(limitError, targetHostId, targetHostName, targetHostIp) { // Remove existing modal if any const existingModal = document.getElementById("sessionLimitModal"); if (existingModal) existingModal.remove(); const sessionsHtml = limitError.active_sessions .map( (s) => `
${this.escapeHtml(s.host_name)} ${s.mode} ${this.formatDuration(s.age_seconds)}
`, ) .join(""); const canReuseHtml = limitError.can_reuse ? `

Une session existe déjà pour cet hôte. Vous pouvez la réutiliser.

` : ""; const modal = document.createElement("div"); modal.id = "sessionLimitModal"; modal.className = "modal-overlay"; modal.innerHTML = ` `; document.body.appendChild(modal); requestAnimationFrame(() => modal.classList.add("show")); } closeSessionLimitModal() { const modal = document.getElementById("sessionLimitModal"); if (modal) { modal.classList.remove("show"); setTimeout(() => modal.remove(), 300); } } async closeSessionFromModal(sessionId) { try { await this.apiCall(`/api/terminal/sessions/${sessionId}`, { method: "DELETE" }); // Remove item from modal const item = document.querySelector(`[data-session-id="${sessionId}"]`); if (item) { item.style.opacity = "0.5"; item.querySelector("button").disabled = true; item.querySelector("button").innerHTML = ' Fermée'; } this.showNotification("Session fermée", "success"); } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } } async closeOldestSessionFromModal(targetHostId, targetHostName, targetHostIp) { try { // Get current sessions const sessions = await this.apiCall("/api/terminal/sessions"); if (sessions.sessions && sessions.sessions.length > 0) { // Find oldest (last in list since sorted by created_at desc) const oldest = sessions.sessions[sessions.sessions.length - 1]; await this.apiCall(`/api/terminal/sessions/${oldest.session_id}`, { method: "DELETE" }); this.showNotification(`Session fermée: ${oldest.host_name}`, "success"); } // Close modal and retry this.closeSessionLimitModal(); await this.openTerminal(targetHostId, targetHostName, targetHostIp); } catch (e) { this.showNotification(`Erreur: ${e.message}`, "error"); } } async reuseSessionFromModal(sessionId, targetHostId, targetHostName, targetHostIp) { this.closeSessionLimitModal(); // Just retry - the server will return the reusable session await this.openTerminal(targetHostId, targetHostName, targetHostIp); } formatDuration(seconds) { if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; } openCurrentTerminalPopout() { if (!this.terminalSession) { this.showNotification("Aucune session terminal active", "warning"); return; } // If the drawer iframe is currently connected, ttyd is started with --once so // we must release the existing client before opening the popout. if (this.terminalDrawerOpen) { try { const iframe = document.getElementById("terminalIframe"); if (iframe) { iframe.src = "about:blank"; } } catch (e) { // Ignore } } const sessionId = this.terminalSession.session_id; const token = this.terminalSession.token; const popupUrl = sessionId && token ? `/api/terminal/popout/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(token)}` : this.terminalSession.url; const popupFeatures = "width=900,height=600,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no"; window.open(popupUrl, `terminal_${this.terminalSession.session_id}`, popupFeatures); // Close the drawer UI but keep the backend session alive for the popout. if (this.terminalDrawerOpen) { this.closeTerminalDrawer({ closeSession: false }); } } copySSHCommand() { if (!this.terminalSession) { this.showNotification("Aucune session terminal active", "warning"); return; } const cmd = `ssh automation@${this.terminalSession.host.ip}`; this.copyTextToClipboard(cmd) .then(() => { this.showNotification(`Commande copiée: ${cmd}`, "success"); }) .catch(() => { this.showNotification("Impossible de copier dans le presse-papier", "error"); }); } async reconnectTerminal() { if (!this.terminalSession) { this.showNotification("Aucune session terminal active", "warning"); return; } const { host } = this.terminalSession; // Close current session await this.closeTerminalDrawer(); // Reopen await this.openTerminal(host.id, host.name, host.ip); } // ===== TERMINAL COMMAND HISTORY ===== async logTerminalCommand(command) { try { if (!this.terminalSession) return; const sessionId = this.terminalSession.session_id; const token = this.terminalSession.token; const cmd = String(command ?? "").trim(); if (!cmd) return; const endpoint = `/api/terminal/sessions/${encodeURIComponent(sessionId)}/command?token=${encodeURIComponent(token)}`; await this.apiCall(endpoint, { method: "POST", body: JSON.stringify({ command: cmd }), }); } catch (e) { // Best-effort: do not disrupt UX if logging fails } } async toggleTerminalHistory() { if (this.terminalHistoryPanelOpen) { this.closeTerminalHistoryPanel(true); } else { this.openTerminalHistoryPanel(); } } async openTerminalHistoryPanel() { const panel = document.getElementById("terminalHistoryPanel"); const btn = document.getElementById("terminalHistoryBtn"); if (!panel) return; this.terminalHistoryPanelOpen = true; this.terminalHistorySelectedIndex = -1; panel.style.display = "flex"; panel.classList.add("open"); btn?.classList.add("active"); // Load history if not loaded if (this.terminalCommandHistory.length === 0) { await this.loadTerminalCommandHistory(); } // Focus search input const searchInput = document.getElementById("terminalHistorySearch"); if (searchInput) { searchInput.focus(); searchInput.select(); } } closeTerminalHistoryPanel(force = false) { // If panel is pinned/docked, don't close on command execute unless forced if (this.terminalHistoryPanelPinned && !force) return; const panel = document.getElementById("terminalHistoryPanel"); const btn = document.getElementById("terminalHistoryBtn"); if (!panel) return; this.terminalHistoryPanelOpen = false; this.terminalHistorySelectedIndex = -1; panel.classList.remove("open"); setTimeout(() => { panel.style.display = "none"; }, 200); btn?.classList.remove("active"); // Return focus to terminal const iframe = document.getElementById("terminalIframe"); if (iframe) { iframe.focus(); } } async loadTerminalCommandHistory(query = "") { if (!this.terminalSession) return; const listEl = document.getElementById("terminalHistoryList"); if (!listEl) return; this.terminalHistoryLoading = true; listEl.innerHTML = '
Chargement...
'; try { const allHosts = document.getElementById("terminalHistoryAllHosts")?.checked || false; const hostId = this.terminalSession.host.id; const timeFilter = this.terminalHistoryTimeFilter; const params = new URLSearchParams(); params.set("limit", "100"); if (query) params.set("query", query); let endpoint; if (allHosts) { // Global history - pass session token for auth in the pop-out context const token = this.terminalSession.token; if (token) params.set("token", token); endpoint = `/api/terminal/command-history?${params.toString()}`; } else { // Per-host unique commands (includes command_hash for pinning) const token = this.terminalSession.token; if (token) params.set("token", token); endpoint = `/api/terminal/${hostId}/command-history/unique?${params.toString()}`; } const response = await this.apiCall(endpoint); let commands = response.commands || []; // Client-side time filtering if (timeFilter !== "all") { const now = new Date(); let cutoff; switch (timeFilter) { case "today": cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate()); break; case "week": cutoff = new Date(now.getTime() - 7 * 86400000); break; case "month": cutoff = new Date(now.getTime() - 30 * 86400000); break; } if (cutoff) { commands = commands.filter((cmd) => { const cmdDate = new Date(cmd.last_used || cmd.created_at); return cmdDate >= cutoff; }); } } // Client-side pinned-only filter if (this.terminalHistoryPinnedOnly) { commands = commands.filter((cmd) => cmd.is_pinned); } this.terminalCommandHistory = commands; this.terminalHistorySelectedIndex = -1; this.renderTerminalHistory(); } catch (e) { console.error("Failed to load terminal history:", e); listEl.innerHTML = '
Erreur de chargement
'; } finally { this.terminalHistoryLoading = false; } } renderTerminalHistory(highlightQuery = "") { const listEl = document.getElementById("terminalHistoryList"); if (!listEl) return; const query = highlightQuery || this.terminalHistorySearchQuery || ""; if (this.terminalCommandHistory.length === 0) { const emptyMessage = query ? `Aucun résultat pour "${this.escapeHtml(query)}"` : "Aucune commande dans l'historique"; listEl.innerHTML = `
${emptyMessage} ${query ? 'Essayez une recherche différente' : ""}
`; return; } const items = this.terminalCommandHistory .map((cmd, index) => { const command = cmd.command || ""; const timeAgo = this.formatRelativeTime(cmd.last_used || cmd.created_at); const execCount = cmd.execution_count || 1; const hostName = cmd.host_name || ""; const isSelected = index === this.terminalHistorySelectedIndex; // Highlight search query in command let displayCommand = this.escapeHtml(command.length > 80 ? command.substring(0, 80) + "..." : command); if (query) { const regex = new RegExp(`(${this.escapeRegExp(query)})`, "gi"); displayCommand = displayCommand.replace(regex, "$1"); } return `
${cmd.is_pinned ? '' : ""}${displayCommand}
${timeAgo} ${execCount > 1 ? `×${execCount}` : ""} ${hostName ? `${this.escapeHtml(hostName)}` : ""}
`; }) .join(""); listEl.innerHTML = items; // Scroll selected item into view if (this.terminalHistorySelectedIndex >= 0) { const selectedEl = listEl.querySelector(".terminal-history-item.selected"); if (selectedEl) { selectedEl.scrollIntoView({ block: "nearest", behavior: "smooth" }); } } } escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } handleHistorySearchKeydown(event) { const key = event.key; const historyLength = this.terminalCommandHistory.length; switch (key) { case "ArrowDown": event.preventDefault(); if (historyLength > 0) { this.terminalHistorySelectedIndex = Math.min(this.terminalHistorySelectedIndex + 1, historyLength - 1); this.renderTerminalHistory(); } break; case "ArrowUp": event.preventDefault(); if (historyLength > 0) { this.terminalHistorySelectedIndex = Math.max(this.terminalHistorySelectedIndex - 1, 0); this.renderTerminalHistory(); } break; case "Enter": event.preventDefault(); if (this.terminalHistorySelectedIndex >= 0) { this.selectAndInsertHistoryCommand(this.terminalHistorySelectedIndex); } else if (historyLength > 0) { this.selectAndInsertHistoryCommand(0); } break; case "Escape": event.preventDefault(); this.closeTerminalHistoryPanel(); break; case "Tab": // Tab to cycle through results event.preventDefault(); if (event.shiftKey) { this.terminalHistorySelectedIndex = Math.max(this.terminalHistorySelectedIndex - 1, 0); } else { this.terminalHistorySelectedIndex = Math.min(this.terminalHistorySelectedIndex + 1, historyLength - 1); } this.renderTerminalHistory(); break; } } searchTerminalHistory(query) { this.terminalHistorySearchQuery = query; this.terminalHistorySelectedIndex = -1; // Debounce search if (this._historySearchTimeout) { clearTimeout(this._historySearchTimeout); } this._historySearchTimeout = setTimeout(() => { this.loadTerminalCommandHistory(query); }, 250); } clearTerminalHistorySearch() { const input = document.getElementById("terminalHistorySearch"); if (input) { input.value = ""; input.focus(); } this.terminalHistorySearchQuery = ""; this.terminalHistorySelectedIndex = -1; this.loadTerminalCommandHistory(""); } setHistoryTimeFilter(value) { this.terminalHistoryTimeFilter = value; this.terminalHistorySelectedIndex = -1; this.loadTerminalCommandHistory(this.terminalHistorySearchQuery); } toggleHistoryScope() { this.terminalHistorySelectedIndex = -1; this.loadTerminalCommandHistory(this.terminalHistorySearchQuery); } togglePinnedOnlyFilter() { this.terminalHistoryPinnedOnly = !this.terminalHistoryPinnedOnly; const btn = document.getElementById("terminalHistoryPinnedOnly"); if (btn) btn.classList.toggle("active", this.terminalHistoryPinnedOnly); this.terminalHistorySelectedIndex = -1; this.loadTerminalCommandHistory(this.terminalHistorySearchQuery); } toggleHistoryPanelPin() { this.terminalHistoryPanelPinned = !this.terminalHistoryPanelPinned; const btn = document.getElementById("terminalHistoryDockBtn"); if (btn) btn.classList.toggle("active", this.terminalHistoryPanelPinned); // In pinned mode, reposition panel as docked above terminal body const panel = document.getElementById("terminalHistoryPanel"); if (panel) panel.classList.toggle("docked", this.terminalHistoryPanelPinned); } async togglePinHistory(index) { if (!this.terminalSession) return; const cmd = this.terminalCommandHistory[index]; if (!cmd || !cmd.command_hash) return; const newPinned = !cmd.is_pinned; try { const hId = cmd.host_id || this.terminalSession.host.id; await this.apiCall(`/api/terminal/${hId}/command-history/${cmd.command_hash}/pin`, { method: "POST", body: JSON.stringify({ is_pinned: newPinned }), }); cmd.is_pinned = newPinned; this.renderTerminalHistory(this.terminalHistorySearchQuery); } catch (e) { this.showNotification("Impossible de modifier l'épingle", "error"); } } // Find the xterm instance inside the terminal iframe (or nested iframe) _getTermFromIframe() { // The side-terminal embeds the connect page in #terminalIframe // Inside that page, the ttyd terminal runs in #terminalFrame // We try both levels. const iframe = document.getElementById("terminalIframe"); if (!iframe || !iframe.contentWindow) return null; try { // Level 1: the connect page itself (pop-out case) const cw1 = iframe.contentWindow; if (cw1.term && typeof cw1.term.paste === "function") return { term: cw1.term, win: cw1 }; // Level 2: nested #terminalFrame inside connect page (side-terminal case) const inner = cw1.document && cw1.document.getElementById("terminalFrame"); if (inner && inner.contentWindow) { const cw2 = inner.contentWindow; if (cw2.term && typeof cw2.term.paste === "function") return { term: cw2.term, win: cw2 }; } } catch (e) { /* cross-origin */ } return null; } selectAndInsertHistoryCommand(index) { const cmd = this.terminalCommandHistory[index]; if (!cmd) return; const command = cmd.command || ""; const found = this._getTermFromIframe(); if (found) { found.term.focus(); found.term.paste(command); this.closeTerminalHistoryPanel(); return; } // Fallback: postMessage to connect page const iframe = document.getElementById("terminalIframe"); if (iframe && iframe.contentWindow) { iframe.contentWindow.postMessage({ type: "terminal:paste", text: command }, "*"); return; } // Last resort: clipboard this.copyTextToClipboard(command) .then(() => { this.showNotification("Commande copiée - Collez avec Ctrl+Shift+V", "success"); }) .catch(() => { }); } executeHistoryCommand(index) { const cmd = this.terminalCommandHistory[index]; if (!cmd) return; const command = cmd.command || ""; const found = this._getTermFromIframe(); if (found) { found.term.focus(); found.term.paste(command + "\r"); this.closeTerminalHistoryPanel(); return; } // Fallback: postMessage to connect page const iframe = document.getElementById("terminalIframe"); if (iframe && iframe.contentWindow) { iframe.contentWindow.postMessage({ type: "terminal:paste", text: command + "\r" }, "*"); this.closeTerminalHistoryPanel(); return; } // Last resort: clipboard + newline this.copyTextToClipboard(command + "\n") .then(() => { this.showNotification("Commande copiée - Collez pour exécuter", "success"); this.closeTerminalHistoryPanel(); }) .catch(() => { }); } copyTerminalCommand(index) { const cmd = this.terminalCommandHistory[index]; if (!cmd) return; const command = cmd.command || ""; this.copyTextToClipboard(command) .then(() => { this.showNotification("Commande copiée", "success"); // Best-effort log this.logTerminalCommand(command); }) .catch(() => { this.showNotification("Impossible de copier", "error"); }); } formatRelativeTime(dateStr) { if (!dateStr) return ""; const date = new Date(dateStr); const now = new Date(); const diffMs = now - date; const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); if (diffSec < 60) return "À l'instant"; if (diffMin < 60) return `Il y a ${diffMin}min`; if (diffHour < 24) return `Il y a ${diffHour}h`; if (diffDay < 7) return `Il y a ${diffDay}j`; return date.toLocaleDateString("fr-FR", { day: "numeric", month: "short" }); } } // Initialize dashboard when DOM is loaded 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. window.addEventListener("message", (event) => { const data = event?.data; if (!data || typeof data !== "object") return; if (data.type === "terminal:closeDrawer") { if (window.dashboard && typeof window.dashboard.closeTerminalDrawer === "function") { window.dashboard.closeTerminalDrawer(); } } if (data.type === "terminal:reconnect") { if (window.dashboard && typeof window.dashboard.reconnectTerminal === "function") { window.dashboard.reconnectTerminal(); } } }); }); // Global functions for onclick handlers function showQuickActions() { dashboard.showQuickActions(); } function executeTask(taskType) { dashboard.executeTask(taskType); } function addHost() { dashboard.addHost(); } function refreshTasks() { dashboard.refreshTasks(); } function clearLogs() { dashboard.clearLogs(); } function exportLogs() { dashboard.exportLogs(); } function closeModal() { dashboard.closeModal(); } window.showCreateScheduleModal = function (prefilledPlaybook = null) { if (!window.dashboard) { return; } if (typeof dashboard.showCreateScheduleModal === "function") { dashboard.showCreateScheduleModal(prefilledPlaybook); return; } try { dashboard.editingScheduleId = null; dashboard.scheduleModalStep = 1; if (typeof dashboard.getScheduleModalContent === "function" && typeof dashboard.showModal === "function") { const content = dashboard.getScheduleModalContent(null, prefilledPlaybook || null); dashboard.showModal("Nouveau Schedule", content, "schedule-modal"); } } catch (e) { console.error("showCreateScheduleModal fallback error:", e); } }; if (typeof window !== "undefined") { window.DashboardManager = DashboardManager; } // Export for testing (ESM/CommonJS compatible) if (typeof module !== "undefined" && module.exports) { module.exports = { DashboardManager }; }