// Homelab Dashboard JavaScript - Intégration API class DashboardManager { constructor() { // Configuration API this.apiKey = 'dev-key-12345'; this.apiBase = window.location.origin; // Données locales (seront remplies par l'API) this.hosts = []; this.tasks = []; this.logs = []; this.ansibleHosts = []; this.ansibleGroups = []; this.playbooks = []; // 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(); this.currentGroupFilter = 'all'; this.currentBootstrapFilter = 'all'; this.currentCategoryFilter = 'all'; this.currentSubcategoryFilter = 'all'; this.currentTargetFilter = 'all'; // 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 = []; // 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; // 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; this.init(); } async init() { this.setupEventListeners(); this.setupScrollAnimations(); this.startAnimations(); this.loadThemePreference(); // Charger les données depuis l'API await this.loadAllData(); // Connecter WebSocket pour les mises à jour temps réel this.connectWebSocket(); // Rafraîchir périodiquement les métriques setInterval(() => this.loadMetrics(), 30000); // Démarrer le polling des tâches en cours this.startRunningTasksPolling(); } // ===== API CALLS ===== async apiCall(endpoint, options = {}) { const url = `${this.apiBase}${endpoint}`; const defaultOptions = { headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' } }; try { const response = await fetch(url, { ...defaultOptions, ...options }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } 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, schedulesData, schedulesStatsData] = await Promise.all([ this.apiCall('/api/hosts').catch(() => []), this.apiCall('/api/tasks').catch(() => []), this.apiCall('/api/logs').catch(() => []), this.apiCall('/api/metrics').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/schedules').catch(() => ({ schedules: [], count: 0 })), this.apiCall('/api/schedules/stats').catch(() => ({ stats: {}, upcoming: [] })) ]); this.hosts = hostsData; this.tasks = tasksData; this.logs = logsData; this.ansibleHosts = inventoryData.hosts || []; this.ansibleGroups = inventoryData.groups || []; this.playbooks = playbooksData.playbooks || []; this.playbookCategories = playbooksData.categories || {}; // 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 || []; // Schedules (Planificateur) this.schedules = schedulesData.schedules || []; this.schedulesStats = schedulesStatsData.stats || { total: 0, active: 0, paused: 0, failures_24h: 0 }; this.schedulesUpcoming = schedulesStatsData.upcoming || []; console.log('Data loaded:', { taskLogs: this.taskLogs.length, taskLogsStats: this.taskLogsStats, adhocHistory: this.adhocHistory.length, adhocCategories: this.adhocCategories.length, schedules: this.schedules.length }); // Mettre à jour l'affichage this.renderHosts(); this.renderTasks(); this.renderLogs(); this.renderPlaybooks(); this.renderSchedules(); 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/metrics'); 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 ===== connectWebSocket() { 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.ws.onmessage = (event) => { const data = JSON.parse(event.data); this.handleWebSocketMessage(data); }; this.ws.onclose = () => { console.log('WebSocket déconnecté, reconnexion dans 5s...'); setTimeout(() => this.connectWebSocket(), 5000); }; this.ws.onerror = (error) => { console.error('WebSocket erreur:', error); }; } catch (error) { console.error('Erreur WebSocket:', error); } } handleWebSocketMessage(data) { 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 'host_created': case 'host_deleted': this.loadAllData(); break; case 'new_log': case 'logs_cleared': this.loadLogs(); 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; } } // ===== 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('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '--'; const duration = task.duration || this.calculateDuration(task.start_time); const progress = task.progress || 0; return `

${this.escapeHtml(task.name)}

En cours

Cible: ${this.escapeHtml(task.host)}

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

${progress}% complété

`; } 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'); } handleTaskProgress(progressData) { console.log('Progression tâche:', progressData); // Mettre à jour la tâche dans la liste const task = this.tasks.find(t => t.id === progressData.task_id); if (task) { task.progress = progressData.progress; // Mettre à jour l'UI de cette tâche spécifique const taskCard = document.querySelector(`.task-card-${progressData.task_id}`); 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); // Retirer la tâche de la liste des tâches en cours this.tasks = this.tasks.filter(t => t.id !== taskData.task_id); // 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(); // Notification const status = taskData.status || 'completed'; const isSuccess = status === 'completed'; this.showNotification( `Tâche terminée: ${isSuccess ? 'Succès' : 'Échec'}`, isSuccess ? 'success' : 'error' ); } 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); } } setupEventListeners() { // Theme toggle const themeToggle = document.getElementById('theme-toggle'); if (themeToggle) { themeToggle.addEventListener('click', () => { this.toggleTheme(); }); } // Initialiser le calendrier de filtrage des tâches this.setupTaskDateCalendar(); // Navigation est gérée par le script de navigation des pages dans index.html } // ===== 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 currentTheme = body.classList.contains('light-theme') ? 'light' : 'dark'; const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; if (newTheme === 'light') { body.classList.add('light-theme'); document.getElementById('theme-toggle').innerHTML = ''; } else { body.classList.remove('light-theme'); document.getElementById('theme-toggle').innerHTML = ''; } // Persist theme preference localStorage.setItem('theme', newTheme); } loadThemePreference() { const savedTheme = localStorage.getItem('theme'); if (savedTheme === 'light') { document.body.classList.add('light-theme'); document.getElementById('theme-toggle').innerHTML = ''; } } renderHosts() { const container = document.getElementById('hosts-list'); const hostsPageContainer = document.getElementById('hosts-page-list'); if (!container && !hostsPageContainer) return; // Filtrer les hôtes par groupe si un filtre est actif let filteredHosts = this.hosts; if (this.currentGroupFilter && this.currentGroupFilter !== 'all') { filteredHosts = this.hosts.filter(h => h.groups && h.groups.includes(this.currentGroupFilter) ); } // Filtrer par statut bootstrap si un filtre est actif if (this.currentBootstrapFilter && this.currentBootstrapFilter !== 'all') { if (this.currentBootstrapFilter === 'ready') { filteredHosts = filteredHosts.filter(h => h.bootstrap_ok); } else if (this.currentBootstrapFilter === 'not_configured') { filteredHosts = filteredHosts.filter(h => !h.bootstrap_ok); } } // 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; // Options des groupes pour le filtre const groupOptions = this.ansibleGroups.map(g => `` ).join(''); // Header avec filtres et boutons - Design professionnel const headerHtml = `
${filteredHosts.length}/${this.hosts.length} hôtes ${readyCount} Ready ${notConfiguredCount} Non configuré
Filtres:
`; // Apply to both containers const containers = [container, hostsPageContainer].filter(c => c); containers.forEach(c => c.innerHTML = headerHtml); if (filteredHosts.length === 0) { const emptyHtml = `

Aucun hôte trouvé ${this.currentGroupFilter !== 'all' ? `dans le groupe "${this.currentGroupFilter}"` : ''} ${this.currentBootstrapFilter && this.currentBootstrapFilter !== 'all' ? `avec le statut "${this.currentBootstrapFilter === 'ready' ? 'Ansible Ready' : 'Non configuré'}"` : ''}

`; containers.forEach(c => c.innerHTML += emptyHtml); return; } filteredHosts.forEach(host => { const statusClass = `status-${host.status}`; // Formater last_seen const lastSeen = host.last_seen ? new Date(host.last_seen).toLocaleString('fr-FR') : 'Jamais vérifié'; // Indicateur de bootstrap const bootstrapOk = host.bootstrap_ok || false; const bootstrapDate = host.bootstrap_date ? new Date(host.bootstrap_date).toLocaleDateString('fr-FR') : null; const bootstrapIndicator = bootstrapOk ? ` Ansible Ready ` : ` Non configuré `; // Indicateur de qualité de communication const commQuality = this.getHostCommunicationQuality(host); const commIndicator = `
${[1,2,3,4,5].map(i => `
`).join('')}
${commQuality.label}
`; const hostCard = document.createElement('div'); hostCard.className = 'host-card group'; // Séparer les groupes env et role 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(''); hostCard.innerHTML = `

${host.name}

${bootstrapIndicator}

${host.ip} • ${host.os}${envGroup ? ` (${envGroup})` : ''}

${roleBadges}
${commIndicator}
`; // Append to all containers containers.forEach(c => { const clonedCard = hostCard.cloneNode(true); c.appendChild(clonedCard); }); }); } filterHostsByBootstrap(status) { this.currentBootstrapFilter = status; this.renderHosts(); } // 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 disponibles try { const pbResult = await this.apiCall('/api/ansible/playbooks'); const playbooks = (pbResult && pbResult.playbooks) ? pbResult.playbooks : []; const playbookOptions = playbooks.map(p => ` `).join(''); const modalContent = `

Hôte cible

${this.escapeHtml(hostName)}

`; 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(); this.showLoading(); 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.hideLoading(); this.showNotification(`Playbook "${playbook}" lancé sur ${hostName}`, 'success'); // Rafraîchir les tâches await this.loadTaskLogsWithFilters(); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, 'error'); } } 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'); } } 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(); // Identifier le groupe d'environnement actuel const currentEnvGroup = (host.groups || []).find(g => g.startsWith('env_')) || ''; const currentRoleGroups = (host.groups || []).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'); } } executePlaybookOnGroup() { const currentGroup = this.currentGroupFilter; // 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' }; let playbooksByCategory = {}; this.playbooks.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}
${playbooksHtml || '

Aucun playbook disponible

'}
`); } async runPlaybookOnTarget(playbook, target) { this.closeModal(); this.showLoading(); 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.hideLoading(); const statusColor = result.success ? 'bg-green-900/30 border-green-600' : 'bg-red-900/30 border-red-600'; const statusIcon = result.success ? 'fa-check-circle text-green-500' : 'fa-times-circle text-red-500'; this.showModal(`Résultat: ${playbook}`, `

${result.success ? 'Exécution réussie' : 'Échec de l\'exécution'}

Durée: ${result.execution_time}s

${this.escapeHtml(result.stdout || '(pas de sortie)')}
${result.stderr ? `

Erreurs:

${this.escapeHtml(result.stderr)}
` : ''}
`); this.showNotification( result.success ? `Playbook ${playbook} exécuté avec succès` : `Échec du playbook ${playbook}`, result.success ? 'success' : 'error' ); await this.loadAllData(); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, 'error'); } } 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(''); // Vérifier si des filtres sont actifs const hasActiveFilters = (this.currentTargetFilter && this.currentTargetFilter !== 'all') || (this.currentCategoryFilter && this.currentCategoryFilter !== 'all'); // Générer les badges de filtres actifs const activeFiltersHtml = hasActiveFilters ? `
Filtres actifs: ${this.currentTargetFilter && this.currentTargetFilter !== 'all' ? ` ${this.escapeHtml(this.currentTargetFilter)} ` : ''} ${this.currentCategoryFilter && this.currentCategoryFilter !== 'all' ? ` ${this.escapeHtml(this.currentCategoryFilter)} ` : ''}
` : ''; // Header avec filtres de catégorie, target et bouton console const headerHtml = `
${filteredLogs.length} log(s) ${runningTasks.length > 0 ? ` ${runningTasks.length} en cours ` : ''}
${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-4'; runningSection.innerHTML = '

En cours

'; const tasksContainer = document.createElement('div'); tasksContainer.className = 'running-tasks-list space-y-2'; 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 = '

Historique des tâches

'; // Utiliser le compteur de pagination const displayCount = Math.min(this.tasksDisplayedCount, filteredLogs.length); filteredLogs.slice(0, displayCount).forEach(log => { logsSection.appendChild(this.createTaskLogCard(log)); }); container.appendChild(logsSection); // Afficher la pagination si nécessaire const paginationEl = document.getElementById('tasks-pagination'); if (paginationEl) { if (filteredLogs.length > this.tasksDisplayedCount) { paginationEl.classList.remove('hidden'); // Mettre à jour le texte du bouton avec le nombre restant const remaining = filteredLogs.length - this.tasksDisplayedCount; paginationEl.innerHTML = ` `; } else { paginationEl.classList.add('hidden'); } } } else if (runningTasks.length === 0) { container.innerHTML += `

Aucune tâche trouvée

Utilisez "Actions Rapides" ou la Console pour lancer une commande

`; } } loadMoreTasks() { // Augmenter le compteur de tâches affichées this.tasksDisplayedCount += this.tasksPerPage; // 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); } // Récupérer la section des logs const logsSection = document.getElementById('task-logs-section'); if (!logsSection) { // Si la section n'existe pas, re-render tout this.renderTasks(); return; } // Ajouter les nouvelles tâches const startIndex = this.tasksDisplayedCount - this.tasksPerPage; const endIndex = Math.min(this.tasksDisplayedCount, filteredLogs.length); for (let i = startIndex; i < endIndex; i++) { logsSection.appendChild(this.createTaskLogCard(filteredLogs[i])); } // Mettre à jour le bouton de pagination const paginationEl = document.getElementById('tasks-pagination'); if (paginationEl) { if (filteredLogs.length > this.tasksDisplayedCount) { const remaining = filteredLogs.length - this.tasksDisplayedCount; paginationEl.innerHTML = ` `; } else { paginationEl.classList.add('hidden'); } } } createTaskLogCard(log) { const statusColors = { 'completed': 'border-green-500 bg-green-500/10', 'failed': 'border-red-500 bg-red-500/10', 'running': 'border-blue-500 bg-blue-500/10', 'pending': 'border-yellow-500 bg-yellow-500/10' }; const statusIcons = { 'completed': '', 'failed': '', 'running': '', 'pending': '' }; // Formater les heures de début et fin 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('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } catch { return null; } }; // Formater la durée const formatDuration = (seconds) => { if (!seconds || seconds <= 0) return null; if (seconds < 60) return `${seconds} seconde${seconds > 1 ? 's' : ''}`; if (seconds < 3600) { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return secs > 0 ? `${mins} minute${mins > 1 ? 's' : ''} ${secs} seconde${secs > 1 ? 's' : ''}` : `${mins} minute${mins > 1 ? 's' : ''}`; } const hours = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; let result = `${hours} heure${hours > 1 ? 's' : ''}`; if (mins > 0) result += ` ${mins} minute${mins > 1 ? 's' : ''}`; if (secs > 0) result += ` ${secs} seconde${secs > 1 ? 's' : ''}`; return result; }; const startTime = formatTime(log.start_time); const endTime = formatTime(log.end_time); const duration = log.duration_seconds ? formatDuration(log.duration_seconds) : (log.duration && log.duration !== 'N/A' ? log.duration : null); // Générer les badges d'hôtes const hostsHtml = log.hosts && log.hosts.length > 0 ? `
${log.hosts.slice(0, 8).map(host => ` ${this.escapeHtml(host)} `).join('')} ${log.hosts.length > 8 ? `+${log.hosts.length - 8} autres` : ''}
` : ''; // Badge de catégorie const categoryBadge = log.category ? ` ${this.escapeHtml(log.category)}${log.subcategory ? ` / ${this.escapeHtml(log.subcategory)}` : ''} ` : ''; // Cible cliquable const targetHtml = log.target ? ` ${this.escapeHtml(log.target)} ` : ''; const card = document.createElement('div'); card.className = `host-card border-l-4 ${statusColors[log.status] || 'border-gray-500'} cursor-pointer hover:bg-opacity-20 transition-all`; card.onclick = () => this.viewTaskLogContent(log.id); card.innerHTML = `
${statusIcons[log.status] || ''}

${this.escapeHtml(log.task_name)}

${this.getStatusBadge(log.status)} ${categoryBadge}
${log.date} ${log.target ? `${targetHtml}` : ''} ${startTime ? `${startTime}` : ''} ${endTime ? `${endTime}` : ''} ${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

                
`; } 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 let outputPreview = ''; if (result.parsedOutput) { const po = result.parsedOutput; if (po.msg) outputPreview = po.msg; else if (po.stdout) outputPreview = po.stdout.substring(0, 100); else if (po.cmd) outputPreview = 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 || ''; navigator.clipboard.writeText(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'); // Recharger la liste des logs avec les filtres courants await 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.tasksDisplayedCount = this.tasksPerPage; this.loadTaskLogsWithFilters(); this.showNotification('Tous les filtres effacés', 'info'); } async loadTaskLogsWithFilters() { 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); } if (this.currentTargetFilter && this.currentTargetFilter !== 'all') { params.append('target', this.currentTargetFilter); } if (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') { params.append('category', this.currentCategoryFilter); } try { const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`); this.taskLogs = result.logs || []; 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: '' }; } this.updateDateFilters(); this.loadTaskLogsWithFilters(); } clearDateFilters() { this.currentDateFilter = { year: '', month: '', day: '' }; this.selectedTaskDates = []; this.updateDateFilters(); this.renderTaskCalendar(); this.loadTaskLogsWithFilters(); } async refreshTaskLogs() { this.showLoading(); try { const [taskLogsData, taskStatsData, taskDatesData] = await Promise.all([ this.apiCall('/api/tasks/logs'), this.apiCall('/api/tasks/logs/stats'), this.apiCall('/api/tasks/logs/dates') ]); this.taskLogs = taskLogsData.logs || []; this.taskLogsStats = taskStatsData; this.taskLogsDates = taskDatesData; this.renderTasks(); this.updateDateFilters(); this.updateTaskCounts(); this.hideLoading(); this.showNotification('Logs de tâches rafraîchis', 'success'); } catch (error) { this.hideLoading(); this.showNotification(`Erreur: ${error.message}`, 'error'); } } 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) { navigator.clipboard.writeText(text).then(() => { this.showNotification('Copié dans le presse-papiers', 'success'); }).catch(() => { this.showNotification('Erreur lors de la copie', 'error'); }); } 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 { const response = await fetch(`/api/tasks/${taskId}/cancel`, { method: 'POST', headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' } }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Erreur lors de l\'annulation'); } const result = await response.json(); this.showNotification(result.message || '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); } 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 = ''; if (this.logs.length === 0) { container.innerHTML = `

Aucun log disponible

`; return; } this.logs.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.host ? `[${log.host}]` : ''}
`; container.appendChild(logEntry); }); // Auto-scroll to bottom container.scrollTop = container.scrollHeight; } getStatusBadge(status) { const badges = { 'completed': 'Terminé', 'running': 'En cours', 'pending': 'En attente', 'failed': 'Échoué' }; 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'); } } // 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 logText = this.logs.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'); } // ===== GESTION DES PLAYBOOKS ===== 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} playbook${filteredPlaybooks.length > 1 ? 's' : ''}`; } if (filteredPlaybooks.length === 0) { container.innerHTML = `

Aucun playbook trouvé

${this.currentPlaybookSearch || this.currentPlaybookCategoryFilter !== 'all' ? '

Essayez de modifier vos filtres

' : ''}
`; return; } container.innerHTML = filteredPlaybooks.map(pb => this.createPlaybookCardHTML(pb)).join(''); // Mettre à jour dynamiquement les boutons de filtre de catégorie en fonction des playbooks présents this.updatePlaybookCategoryFilters(); } updatePlaybookCategoryFilters() { const container = document.getElementById('playbook-category-filters'); if (!container) return; // Construire la liste des catégories présentes à partir des playbooks const categorySet = new Set(); (this.playbooks || []).forEach(pb => { const cat = (pb.category || 'general').toLowerCase(); categorySet.add(cat); }); const categories = Array.from(categorySet).sort((a, b) => a.localeCompare(b, 'fr')); const buttonsHtml = [ ``, ...categories.map(cat => { const label = this.getCategoryLabel(cat); const icon = this.getPlaybookCategoryIcon(cat); const isActive = this.currentPlaybookCategoryFilter && this.currentPlaybookCategoryFilter.toLowerCase() === cat; const activeClasses = isActive ? 'bg-purple-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'; return ` `; }) ].join(''); container.innerHTML = ` Catégorie: ${buttonsHtml} `; } createPlaybookCardHTML(playbook) { const category = (playbook.category || 'general').toLowerCase(); const categoryClass = `playbook-category-${category}`; const categoryLabel = this.getCategoryLabel(category); // Calculer la taille formatée const sizeKb = playbook.size ? (playbook.size / 1024).toFixed(1) : '?'; // Calculer le temps relatif const modifiedAgo = playbook.modified ? this.getRelativeTime(playbook.modified) : 'Date inconnue'; return `

${this.escapeHtml(playbook.filename)}

${categoryLabel}
${playbook.description ? `

${this.escapeHtml(playbook.description)}

` : ''}
${modifiedAgo} ${sizeKb} KB ${playbook.subcategory ? `${playbook.subcategory}` : ''}
`; } getCategoryLabel(category) { const labels = { 'maintenance': 'Maintenance', 'deploy': 'Deploy', 'backup': 'Backup', 'monitoring': 'Monitoring', 'system': 'System', 'general': 'Général' }; return labels[category] || category; } getPlaybookCategoryIcon(category) { const icons = { 'maintenance': 'fa-wrench', 'deploy': 'fa-rocket', 'backup': 'fa-save', 'monitoring': 'fa-heartbeat', 'system': 'fa-cogs' }; 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('.playbook-filter-btn').forEach(btn => { const btnCategory = btn.dataset.category; if (btnCategory === category) { btn.classList.remove('bg-gray-700', 'text-gray-300'); btn.classList.add('bg-purple-600', 'text-white'); } else { btn.classList.remove('bg-purple-600', 'text-white'); btn.classList.add('bg-gray-700', 'text-gray-300'); } }); 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); // Setup de la validation YAML basique + support de la touche Tab setTimeout(() => { 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; } }); } }, 100); } 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(); // Afficher le résultat const statusColor = result.success ? 'bg-green-900/30 border-green-600' : 'bg-red-900/30 border-red-600'; const statusIcon = result.success ? 'fa-check-circle text-green-500' : 'fa-times-circle text-red-500'; this.showModal(`Résultat: ${filename}`, `

${result.success ? 'Exécution réussie' : 'Échec de l\'exécution'}

Cible: ${target} • Durée: ${result.execution_time || '?'}s

${this.escapeHtml(result.stdout || '(pas de sortie)')}
${result.stderr ? `

Erreurs:

${this.escapeHtml(result.stderr)}
` : ''}
`); this.showNotification( result.success ? `Playbook exécuté avec succès` : `Échec du playbook`, result.success ? 'success' : 'error' ); // Rafraîchir les tâches 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 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 = `

Aucun schedule trouvé pour ces critères

`; 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(''); return `
${schedule.name} ${statusText} ${tagsHtml}
${schedule.description || ''}
${schedule.playbook} ${schedule.target} ${recurrenceText}
Prochaine: ${nextRunText} ${lastRunHtml}
${schedule.enabled ? ` ` : ` `}
`; } updateSchedulesStats() { const activeCount = this.schedules.filter(s => s.enabled).length; const pausedCount = this.schedules.filter(s => !s.enabled).length; const activeCountEl = document.getElementById('schedules-active-count'); if (activeCountEl) activeCountEl.textContent = activeCount; const pausedCountEl = document.getElementById('schedules-paused-count'); if (pausedCountEl) pausedCountEl.textContent = pausedCount; const failuresEl = document.getElementById('schedules-failures-24h'); if (failuresEl) failuresEl.textContent = this.schedulesStats.failures_24h || 0; // Dashboard widget const dashboardActiveEl = document.getElementById('dashboard-schedules-active'); if (dashboardActiveEl) dashboardActiveEl.textContent = activeCount; const dashboardFailuresEl = document.getElementById('dashboard-schedules-failures'); if (dashboardFailuresEl) dashboardFailuresEl.textContent = this.schedulesStats.failures_24h || 0; // 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 ===== showCreateScheduleModal(prefilledPlaybook = null) { this.editingScheduleId = null; this.scheduleModalStep = 1; 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; 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

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('')}
`; } scheduleModalNextStep() { if (this.scheduleModalStep < 3) { this.scheduleModalStep++; this.updateScheduleModalStep(); } } 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; // 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 }; 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') { 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); } } // Initialize dashboard when DOM is loaded document.addEventListener('DOMContentLoaded', () => { console.log('Creating DashboardManager...'); window.dashboard = new DashboardManager(); console.log('DashboardManager created. Methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(dashboard)).filter(m => m.includes('Schedule'))); }); // 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); } };