7886 lines
378 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();
// Filtres d'heure
this.currentHourStart = '';
this.currentHourEnd = '';
// Filtre par type de source (scheduled, manual, adhoc)
this.currentSourceTypeFilter = 'all';
this.currentGroupFilter = 'all';
this.currentBootstrapFilter = 'all';
this.currentCategoryFilter = 'all';
this.currentSubcategoryFilter = 'all';
this.currentTargetFilter = 'all';
// Pagination côté serveur
this.tasksTotalCount = 0;
this.tasksHasMore = false;
// Groupes pour la gestion des hôtes
this.envGroups = [];
this.roleGroups = [];
// Catégories de playbooks
this.playbookCategories = {};
// Filtres playbooks
this.currentPlaybookCategoryFilter = 'all';
this.currentPlaybookSearch = '';
// Historique des commandes ad-hoc
this.adhocHistory = [];
this.adhocCategories = [];
// 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 = '<h4 class="text-sm font-semibold text-blue-400 mb-2"><i class="fas fa-spinner fa-spin mr-2"></i>En cours</h4>';
// 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 `
<div class="host-card border-l-4 border-blue-500 task-card-${task.id}" data-task-id="${task.id}">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-3 mb-1">
<i class="fas fa-cog fa-spin text-blue-500"></i>
<h4 class="font-semibold text-white text-sm">${this.escapeHtml(task.name)}</h4>
<span class="px-2 py-0.5 bg-blue-600 text-xs rounded-full">En cours</span>
</div>
<p class="text-xs text-gray-400 ml-6">Cible: ${this.escapeHtml(task.host)}</p>
<p class="text-xs text-gray-500 ml-6">Début: ${startTime} • Durée: ${duration}</p>
<div class="w-full bg-gray-700 rounded-full h-1.5 mt-2 ml-6">
<div class="bg-blue-500 h-1.5 rounded-full transition-all duration-500 animate-pulse"
style="width: ${progress}%"></div>
</div>
<p class="text-xs text-gray-500 ml-6 mt-1">${progress}% complété</p>
</div>
<div class="flex flex-col space-y-1 ml-2">
<button class="p-1.5 bg-gray-700 rounded hover:bg-gray-600 transition-colors"
onclick="dashboard.viewTaskDetails('${task.id}')" title="Voir les détails">
<i class="fas fa-eye text-gray-300 text-xs"></i>
</button>
<button class="p-1.5 bg-red-700 rounded hover:bg-red-600 transition-colors"
onclick="dashboard.cancelTask('${task.id}')" title="Annuler la tâche">
<i class="fas fa-times text-white text-xs"></i>
</button>
</div>
</div>
</div>
`;
}
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 = '<i class="fas fa-sun text-yellow-400"></i>';
} else {
body.classList.remove('light-theme');
document.getElementById('theme-toggle').innerHTML = '<i class="fas fa-moon text-gray-300"></i>';
}
// 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 = '<i class="fas fa-sun text-yellow-400"></i>';
}
}
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 =>
`<option value="${g}" ${this.currentGroupFilter === g ? 'selected' : ''}>${g}</option>`
).join('');
// Header avec filtres et boutons - Design professionnel
const headerHtml = `
<div class="flex flex-col gap-4 mb-6">
<!-- Ligne 1: Compteur et boutons principaux -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div class="flex items-center space-x-3">
<span class="text-sm text-gray-400">
<i class="fas fa-server mr-2"></i>${filteredHosts.length}/${this.hosts.length} hôtes
</span>
<span class="px-2 py-1 bg-green-600/20 text-green-400 text-xs rounded-full">
<i class="fas fa-check-circle mr-1"></i>${readyCount} Ready
</span>
<span class="px-2 py-1 bg-yellow-600/20 text-yellow-400 text-xs rounded-full">
<i class="fas fa-exclamation-triangle mr-1"></i>${notConfiguredCount} Non configuré
</span>
</div>
<div class="flex items-center space-x-2">
<button class="px-3 py-1.5 bg-green-600 text-xs rounded hover:bg-green-500 transition-colors"
onclick="dashboard.executePlaybookOnGroup()" title="Exécuter un playbook sur le groupe">
<i class="fas fa-play mr-1"></i>Playbook
</button>
<button class="px-3 py-1.5 bg-purple-600 text-xs rounded hover:bg-purple-500 transition-colors" onclick="dashboard.refreshHosts()">
<i class="fas fa-sync-alt mr-1"></i>Rafraîchir
</button>
</div>
</div>
<!-- Ligne 2: Filtres -->
<div class="flex flex-wrap items-center gap-2 p-3 bg-gray-800/50 rounded-lg">
<span class="text-xs text-gray-500 mr-2"><i class="fas fa-filter mr-1"></i>Filtres:</span>
<!-- Filtre par statut bootstrap -->
<div class="flex items-center rounded-lg overflow-hidden border border-gray-600">
<button onclick="dashboard.filterHostsByBootstrap('all')"
class="px-3 py-1.5 text-xs transition-colors ${this.currentBootstrapFilter === 'all' || !this.currentBootstrapFilter ? 'bg-purple-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}">
Tous
</button>
<button onclick="dashboard.filterHostsByBootstrap('ready')"
class="px-3 py-1.5 text-xs transition-colors ${this.currentBootstrapFilter === 'ready' ? 'bg-green-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}">
<i class="fas fa-check-circle mr-1"></i>Ansible Ready
</button>
<button onclick="dashboard.filterHostsByBootstrap('not_configured')"
class="px-3 py-1.5 text-xs transition-colors ${this.currentBootstrapFilter === 'not_configured' ? 'bg-yellow-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}">
<i class="fas fa-exclamation-triangle mr-1"></i>Non configuré
</button>
</div>
<!-- Filtre par groupe -->
<select id="host-group-filter" onchange="dashboard.filterHostsByGroup(this.value)"
class="px-3 py-1.5 bg-gray-700 border border-gray-600 rounded-lg text-xs">
<option value="all" ${this.currentGroupFilter === 'all' ? 'selected' : ''}>Tous les groupes</option>
${groupOptions}
</select>
</div>
</div>
`;
// Apply to both containers
const containers = [container, hostsPageContainer].filter(c => c);
containers.forEach(c => c.innerHTML = headerHtml);
if (filteredHosts.length === 0) {
const emptyHtml = `
<div class="text-center text-gray-500 py-8">
<i class="fas fa-exclamation-circle text-2xl mb-2"></i>
<p>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é'}"` : ''}</p>
<p class="text-sm mt-2">
<button onclick="dashboard.showAddHostModal()" class="text-purple-400 hover:text-purple-300">
<i class="fas fa-plus mr-1"></i>Ajouter un hôte
</button>
</p>
</div>
`;
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
? `<span class="px-2 py-0.5 bg-green-600/30 text-green-400 text-xs rounded-full flex items-center" title="Bootstrap OK - ${bootstrapDate || 'Date inconnue'}">
<i class="fas fa-check-circle mr-1"></i>Ansible Ready
</span>`
: `<span class="px-2 py-0.5 bg-yellow-600/30 text-yellow-400 text-xs rounded-full flex items-center" title="Bootstrap requis">
<i class="fas fa-exclamation-triangle mr-1"></i>Non configuré
</span>`;
// Indicateur de qualité de communication
const commQuality = this.getHostCommunicationQuality(host);
const commIndicator = `
<div class="flex items-center gap-1" title="${commQuality.tooltip}">
<div class="flex items-center gap-0.5">
${[1,2,3,4,5].map(i => `
<div class="w-1 rounded-sm transition-all ${i <= commQuality.level ? commQuality.colorClass : 'bg-gray-600'}"
style="height: ${4 + i * 2}px;"></div>
`).join('')}
</div>
<span class="text-xs ${commQuality.textClass} ml-1">${commQuality.label}</span>
</div>
`;
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
? `<span class="px-2 py-0.5 bg-blue-600/30 text-blue-400 text-xs rounded-full">${envGroup.replace('env_', '')}</span>`
: '';
const roleBadges = roleGroups.map(g =>
`<span class="px-2 py-0.5 bg-purple-600/30 text-purple-400 text-xs rounded">${g.replace('role_', '')}</span>`
).join('');
hostCard.innerHTML = `
<div class="flex items-start justify-between gap-3">
<div class="flex items-start space-x-3 flex-1 min-w-0">
<div class="mt-1">
<div class="status-indicator ${statusClass}"></div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<h4 class="font-semibold text-white truncate">${host.name}</h4>
${bootstrapIndicator}
</div>
<p class="text-sm text-gray-400">${host.ip}${host.os}${envGroup ? ` (${envGroup})` : ''}</p>
<div class="flex flex-wrap gap-1 mt-1">
${roleBadges}
</div>
</div>
</div>
<!-- Indicateur qualité + Last seen + Boutons horizontaux -->
<div class="flex items-center gap-3 flex-shrink-0">
${commIndicator}
<span class="text-xs text-gray-500 hidden sm:inline">${lastSeen}</span>
<div class="flex items-center gap-1">
<button class="p-1.5 bg-gray-700 rounded hover:bg-blue-600 transition-colors" onclick="dashboard.showEditHostModal('${host.name}')" title="Modifier">
<i class="fas fa-pen text-gray-300 text-xs"></i>
</button>
<button class="p-1.5 bg-gray-700 rounded hover:bg-red-600 transition-colors" onclick="dashboard.confirmDeleteHost('${host.name}')" title="Supprimer">
<i class="fas fa-trash text-gray-300 text-xs"></i>
</button>
<button class="p-1.5 bg-gray-700 rounded hover:bg-gray-600 transition-colors" onclick="dashboard.manageHost('${host.name}')" title="Détails">
<i class="fas fa-cog text-gray-300 text-xs"></i>
</button>
</div>
</div>
</div>
<!-- Boutons d'action horizontaux -->
<div class="mt-3 flex flex-wrap items-center gap-2">
<button class="px-3 py-1.5 bg-purple-600 text-xs rounded-lg hover:bg-purple-500 transition-colors flex items-center" onclick="dashboard.executeHostAction('connect', '${host.name}')">
<i class="fas fa-heartbeat mr-1"></i>Health Check
</button>
<button class="px-3 py-1.5 bg-green-600 text-xs rounded-lg hover:bg-green-500 transition-colors flex items-center" onclick="dashboard.executeHostAction('update', '${host.name}')">
<i class="fas fa-arrow-up mr-1"></i>Upgrade
</button>
<button class="px-3 py-1.5 bg-orange-600 text-xs rounded-lg hover:bg-orange-500 transition-colors flex items-center" onclick="dashboard.executeHostAction('reboot', '${host.name}')">
<i class="fas fa-redo mr-1"></i>Reboot
</button>
<button class="px-3 py-1.5 bg-blue-600 text-xs rounded-lg hover:bg-blue-500 transition-colors flex items-center" onclick="dashboard.executeHostAction('backup', '${host.name}')">
<i class="fas fa-save mr-1"></i>Backup
</button>
<button class="px-3 py-1.5 ${bootstrapOk ? 'bg-gray-600' : 'bg-yellow-600 animate-pulse'} text-xs rounded-lg hover:bg-yellow-500 transition-colors flex items-center" onclick="dashboard.showBootstrapModal('${host.name}', '${host.ip}')">
<i class="fas fa-tools mr-1"></i>Bootstrap
</button>
<button class="px-3 py-1.5 bg-teal-600 text-xs rounded-lg hover:bg-teal-500 transition-colors flex items-center" onclick="dashboard.showPlaybookModalForHost('${host.name}')" title="Exécuter un playbook sur cet hôte">
<i class="fas fa-play-circle mr-1"></i>Playbook
</button>
</div>
`;
// 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 compatibles avec cet hôte
try {
const pbResult = await this.apiCall(`/api/ansible/playbooks?target=${encodeURIComponent(hostName)}`);
const playbooks = (pbResult && pbResult.playbooks) ? pbResult.playbooks : [];
const playbookOptions = playbooks.map(p => `
<option value="${p.name}">${p.name}${p.description ? ` - ${p.description}` : ''}</option>
`).join('');
const modalContent = `
<div class="space-y-4">
<div class="flex items-center gap-3 p-3 bg-gray-700/50 rounded-lg">
<i class="fas fa-server text-purple-400 text-xl"></i>
<div>
<p class="text-sm text-gray-400">Hôte cible</p>
<p class="font-semibold text-white">${this.escapeHtml(hostName)}</p>
</div>
</div>
<div class="p-3 bg-blue-900/30 border border-blue-600 rounded-lg text-sm">
<i class="fas fa-info-circle text-blue-400 mr-2"></i>
Seuls les playbooks compatibles avec cet hôte sont affichés (${playbooks.length} disponible${playbooks.length > 1 ? 's' : ''})
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-play-circle mr-2"></i>Playbook à exécuter
</label>
<select id="playbook-select" class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent">
<option value="">-- Sélectionner un playbook --</option>
${playbookOptions}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-cog mr-2"></i>Variables supplémentaires (JSON, optionnel)
</label>
<textarea id="playbook-extra-vars" rows="3"
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent font-mono text-sm"
placeholder='{"key": "value"}'></textarea>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="playbook-check-mode" class="rounded bg-gray-700 border-gray-600 text-teal-500 focus:ring-teal-500">
<label for="playbook-check-mode" class="text-sm text-gray-300">
Mode simulation (--check)
</label>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-700 mt-2">
<button type="button" onclick="dashboard.closeModal()" class="px-4 py-2 bg-gray-600 rounded-lg hover:bg-gray-500 transition-colors text-sm">
Annuler
</button>
<button type="button" onclick="dashboard.executePlaybookOnHost('${this.escapeHtml(hostName)}')" class="px-4 py-2 bg-teal-600 rounded-lg hover:bg-teal-500 transition-colors text-sm inline-flex items-center gap-2">
<i class="fas fa-play"></i>
<span>Exécuter</span>
</button>
</div>
</div>
`;
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 =>
`<option value="${g}">${g.replace('env_', '')}</option>`
).join('');
const roleCheckboxes = this.roleGroups.map(g => `
<label class="flex items-center space-x-2 p-2 bg-gray-700/50 rounded hover:bg-gray-700 cursor-pointer">
<input type="checkbox" name="role_groups" value="${g}" class="form-checkbox bg-gray-600 border-gray-500 rounded text-purple-500">
<span class="text-sm text-gray-300">${g.replace('role_', '')}</span>
</label>
`).join('');
this.showModal('Ajouter un Host', `
<form onsubmit="dashboard.createHost(event)" class="space-y-4">
<div class="p-3 bg-yellow-900/30 border border-yellow-600 rounded-lg text-sm">
<i class="fas fa-exclamation-triangle text-yellow-400 mr-2"></i>
L'hôte sera ajouté au fichier <code class="bg-gray-700 px-1 rounded">hosts.yml</code>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-server mr-1"></i>Nom de l'hôte <span class="text-red-400">*</span>
</label>
<input type="text" name="name" required
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none"
placeholder="server.domain.home" pattern="[a-zA-Z0-9.\-]+" title="Lettres, chiffres, points et tirets uniquement">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-network-wired mr-1"></i>Adresse IP (ansible_host)
</label>
<input type="text" name="ip"
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none"
placeholder="192.168.1.100">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-layer-group mr-1"></i>Groupe d'environnement <span class="text-red-400">*</span>
</label>
<select name="env_group" required class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none">
<option value="">-- Sélectionner --</option>
${envOptions}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-tags mr-1"></i>Groupes de rôles
</label>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-40 overflow-y-auto p-2 bg-gray-800/50 rounded-lg">
${roleCheckboxes || '<p class="text-gray-500 text-sm col-span-full">Aucun groupe de rôle disponible</p>'}
</div>
</div>
<div class="flex space-x-3 pt-4">
<button type="submit" class="flex-1 btn-primary bg-purple-600 hover:bg-purple-500 py-3 rounded-lg font-medium">
<i class="fas fa-save mr-2"></i>Enregistrer
</button>
<button type="button" onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Annuler
</button>
</div>
</form>
`);
}
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 =>
`<option value="${g}" ${g === currentEnvGroup ? 'selected' : ''}>${g.replace('env_', '')}</option>`
).join('');
const roleCheckboxes = this.roleGroups.map(g => `
<label class="flex items-center space-x-2 p-2 bg-gray-700/50 rounded hover:bg-gray-700 cursor-pointer">
<input type="checkbox" name="role_groups" value="${g}"
${currentRoleGroups.includes(g) ? 'checked' : ''}
class="form-checkbox bg-gray-600 border-gray-500 rounded text-purple-500">
<span class="text-sm text-gray-300">${g.replace('role_', '')}</span>
</label>
`).join('');
this.showModal(`Modifier: ${hostName}`, `
<form onsubmit="dashboard.updateHost(event, '${hostName}')" class="space-y-4">
<div class="p-3 bg-yellow-900/30 border border-yellow-600 rounded-lg text-sm">
<i class="fas fa-exclamation-triangle text-yellow-400 mr-2"></i>
Les modifications seront appliquées au fichier <code class="bg-gray-700 px-1 rounded">hosts.yml</code>
</div>
<div class="p-4 bg-gray-800 rounded-lg">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-purple-600 rounded-lg flex items-center justify-center">
<i class="fas fa-server text-white"></i>
</div>
<div>
<h4 class="font-semibold text-white">${hostName}</h4>
<p class="text-sm text-gray-400">${host.ip}</p>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-network-wired mr-1"></i>Adresse IP (ansible_host)
</label>
<input type="text" name="ansible_host" value="${host.ip || ''}"
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none"
placeholder="192.168.1.100">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-layer-group mr-1"></i>Groupe d'environnement
</label>
<select name="env_group" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none">
${envOptions}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-tags mr-1"></i>Groupes de rôles
</label>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-40 overflow-y-auto p-2 bg-gray-800/50 rounded-lg">
${roleCheckboxes || '<p class="text-gray-500 text-sm col-span-full">Aucun groupe de rôle disponible</p>'}
</div>
</div>
<div class="flex space-x-3 pt-4">
<button type="submit" class="flex-1 btn-primary bg-blue-600 hover:bg-blue-500 py-3 rounded-lg font-medium">
<i class="fas fa-save mr-2"></i>Enregistrer
</button>
<button type="button" onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Annuler
</button>
</div>
</form>
`);
}
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', `
<div class="space-y-4">
<div class="p-4 bg-red-900/30 border border-red-600 rounded-lg">
<div class="flex items-start space-x-3">
<i class="fas fa-exclamation-triangle text-red-500 text-2xl mt-1"></i>
<div>
<h4 class="font-semibold text-red-400">Attention !</h4>
<p class="text-sm text-gray-300 mt-1">
Vous êtes sur le point de supprimer l'hôte <strong class="text-white">${hostName}</strong> de l'inventaire Ansible.
</p>
<p class="text-sm text-gray-400 mt-2">
Cette action supprimera l'hôte de tous les groupes et ne peut pas être annulée.
</p>
</div>
</div>
</div>
<div class="flex space-x-3">
<button onclick="dashboard.deleteHost('${hostName}')" class="flex-1 px-4 py-3 bg-red-600 rounded-lg hover:bg-red-500 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>Supprimer définitivement
</button>
<button onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Annuler
</button>
</div>
</div>
`);
}
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}`, `
<form onsubmit="dashboard.createGroup(event, '${type}')" class="space-y-4">
<div class="p-3 bg-${color}-900/30 border border-${color}-600 rounded-lg text-sm">
<i class="fas ${icon} text-${color}-400 mr-2"></i>
Le groupe sera ajouté à l'inventaire Ansible avec le préfixe <code class="bg-gray-700 px-1 rounded">${prefix}</code>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas ${icon} mr-1"></i>Nom du groupe <span class="text-red-400">*</span>
</label>
<div class="flex items-center">
<span class="px-3 py-3 bg-gray-600 border border-gray-500 border-r-0 rounded-l-lg text-gray-400">${prefix}</span>
<input type="text" name="name" required
class="flex-1 p-3 bg-gray-700 border border-gray-600 rounded-r-lg focus:border-${color}-500 focus:outline-none"
placeholder="${type === 'env' ? 'production' : 'webserver'}"
pattern="[a-zA-Z0-9_-]+"
title="Lettres, chiffres, tirets et underscores uniquement">
</div>
</div>
<div class="flex space-x-3 pt-4">
<button type="submit" class="flex-1 btn-primary bg-${color}-600 hover:bg-${color}-500 py-3 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>Créer le groupe
</button>
<button type="button" onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Annuler
</button>
</div>
</form>
`);
}
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 = `
<div class="text-center py-8 text-gray-400">
<i class="fas ${icon} text-4xl mb-3 opacity-50"></i>
<p>Aucun groupe d'${typeLabel} trouvé</p>
<button onclick="dashboard.closeModal(); dashboard.showAddGroupModal('${type}')"
class="mt-4 px-4 py-2 bg-${color}-600 rounded-lg hover:bg-${color}-500 transition-colors">
<i class="fas fa-plus mr-2"></i>Créer un groupe
</button>
</div>
`;
} else {
groupsHtml = `
<div class="space-y-3 max-h-96 overflow-y-auto">
${groups.map(g => `
<div class="flex items-center justify-between p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-${color}-600/30 rounded-lg flex items-center justify-center">
<i class="fas ${icon} text-${color}-400"></i>
</div>
<div>
<h4 class="font-medium text-white">${g.display_name}</h4>
<p class="text-xs text-gray-400">
<code class="bg-gray-600 px-1 rounded">${g.name}</code>
<span class="ml-2">${g.hosts_count} hôte(s)</span>
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<button onclick="dashboard.showEditGroupModal('${g.name}', '${type}')"
class="p-2 text-blue-400 hover:bg-blue-600/20 rounded-lg transition-colors" title="Modifier">
<i class="fas fa-edit"></i>
</button>
<button onclick="dashboard.confirmDeleteGroup('${g.name}', '${type}', ${g.hosts_count})"
class="p-2 text-red-400 hover:bg-red-600/20 rounded-lg transition-colors" title="Supprimer">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`).join('')}
</div>
`;
}
this.showModal(`Gérer les ${typeLabelPlural}`, `
<div class="space-y-4">
<div class="flex items-center justify-between">
<p class="text-gray-400 text-sm">${groups.length} groupe(s) d'${typeLabel}</p>
<button onclick="dashboard.closeModal(); dashboard.showAddGroupModal('${type}')"
class="px-3 py-1.5 bg-${color}-600 rounded-lg hover:bg-${color}-500 transition-colors text-sm">
<i class="fas fa-plus mr-1"></i>Ajouter
</button>
</div>
${groupsHtml}
<div class="flex justify-end pt-4 border-t border-gray-700">
<button onclick="dashboard.closeModal()" class="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Fermer
</button>
</div>
</div>
`);
}
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}`, `
<form onsubmit="dashboard.updateGroup(event, '${groupName}', '${type}')" class="space-y-4">
<div class="p-3 bg-yellow-900/30 border border-yellow-600 rounded-lg text-sm">
<i class="fas fa-exclamation-triangle text-yellow-400 mr-2"></i>
Le renommage affectera tous les hôtes associés à ce groupe.
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas ${icon} mr-1"></i>Nouveau nom <span class="text-red-400">*</span>
</label>
<div class="flex items-center">
<span class="px-3 py-3 bg-gray-600 border border-gray-500 border-r-0 rounded-l-lg text-gray-400">${prefix}</span>
<input type="text" name="new_name" required value="${displayName}"
class="flex-1 p-3 bg-gray-700 border border-gray-600 rounded-r-lg focus:border-${color}-500 focus:outline-none"
pattern="[a-zA-Z0-9_-]+"
title="Lettres, chiffres, tirets et underscores uniquement">
</div>
</div>
<div class="flex space-x-3 pt-4">
<button type="submit" class="flex-1 btn-primary bg-blue-600 hover:bg-blue-500 py-3 rounded-lg font-medium">
<i class="fas fa-save mr-2"></i>Enregistrer
</button>
<button type="button" onclick="dashboard.showManageGroupsModal('${type}')" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Retour
</button>
</div>
</form>
`);
}
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 = `
<div class="mt-4">
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-exchange-alt mr-1"></i>Déplacer les hôtes vers:
</label>
<select id="move-hosts-to" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none">
${otherGroups.length > 0
? otherGroups.map(g => `<option value="${g.name}">${g.display_name}</option>`).join('')
: '<option value="" disabled>Aucun autre groupe disponible</option>'
}
</select>
<p class="text-xs text-gray-400 mt-1">Les ${hostsCount} hôte(s) seront déplacés vers ce groupe.</p>
</div>
`;
}
this.showModal('Confirmer la suppression', `
<div class="space-y-4">
<div class="p-4 bg-red-900/30 border border-red-600 rounded-lg">
<div class="flex items-start space-x-3">
<i class="fas fa-exclamation-triangle text-red-500 text-2xl mt-1"></i>
<div>
<h4 class="font-semibold text-red-400">Attention !</h4>
<p class="text-sm text-gray-300 mt-1">
Vous êtes sur le point de supprimer le groupe d'${typeLabel} <strong class="text-white">${displayName}</strong>.
</p>
${hostsCount > 0 ? `
<p class="text-sm text-yellow-400 mt-2">
<i class="fas fa-users mr-1"></i>Ce groupe contient ${hostsCount} hôte(s).
</p>
` : ''}
</div>
</div>
</div>
${moveOptions}
<div class="flex space-x-3 pt-4">
<button onclick="dashboard.deleteGroup('${groupName}', '${type}')"
class="flex-1 px-4 py-3 bg-red-600 rounded-lg hover:bg-red-500 transition-colors font-medium"
${hostsCount > 0 && type === 'env' && otherGroups.length === 0 ? 'disabled class="opacity-50 cursor-not-allowed"' : ''}>
<i class="fas fa-trash mr-2"></i>Supprimer
</button>
<button onclick="dashboard.showManageGroupsModal('${type}')" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Annuler
</button>
</div>
</div>
`);
}
async deleteGroup(groupName, type) {
// Récupérer le groupe de destination si spécifié
const moveSelect = document.getElementById('move-hosts-to');
const moveHostsTo = moveSelect ? moveSelect.value : null;
this.closeModal();
this.showLoading();
try {
let url = `/api/groups/${encodeURIComponent(groupName)}`;
if (moveHostsTo) {
url += `?move_hosts_to=${encodeURIComponent(moveHostsTo)}`;
}
const result = await this.apiCall(url, {
method: 'DELETE'
});
this.hideLoading();
this.showNotification(result.message || `Groupe supprimé avec succès`, 'success');
// Recharger les groupes et afficher la liste
await this.loadHostGroups();
await this.loadAllData();
this.showManageGroupsModal(type);
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur: ${error.message}`, 'error');
}
}
async executePlaybookOnGroup() {
const currentGroup = this.currentGroupFilter;
// Charger les playbooks compatibles avec ce groupe
let compatiblePlaybooks = [];
try {
const pbResult = await this.apiCall(`/api/ansible/playbooks?target=${encodeURIComponent(currentGroup)}`);
compatiblePlaybooks = (pbResult && pbResult.playbooks) ? pbResult.playbooks : [];
} catch (error) {
this.showNotification(`Erreur chargement playbooks: ${error.message}`, 'error');
return;
}
// Générer la liste des playbooks groupés par catégorie
const categoryColors = {
'maintenance': 'text-orange-400',
'monitoring': 'text-green-400',
'backup': 'text-blue-400',
'general': 'text-gray-400',
'testing': 'text-purple-400'
};
let playbooksByCategory = {};
compatiblePlaybooks.forEach(pb => {
const cat = pb.category || 'general';
if (!playbooksByCategory[cat]) playbooksByCategory[cat] = [];
playbooksByCategory[cat].push(pb);
});
let playbooksHtml = '';
Object.entries(playbooksByCategory).forEach(([category, playbooks]) => {
const colorClass = categoryColors[category] || 'text-gray-400';
playbooksHtml += `
<div class="mb-4">
<h5 class="text-xs font-semibold ${colorClass} uppercase mb-2">
<i class="fas fa-folder mr-1"></i>${category}
</h5>
<div class="space-y-2">
${playbooks.map(pb => `
<button class="w-full p-3 bg-gray-700 rounded-lg text-left hover:bg-gray-600 transition-colors"
onclick="dashboard.runPlaybookOnTarget('${pb.filename}', '${currentGroup}')">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-white">${pb.name}</span>
${pb.description ? `<p class="text-xs text-gray-400 mt-1">${pb.description}</p>` : ''}
</div>
<span class="text-xs text-gray-500 px-2 py-1 bg-gray-600 rounded">${pb.subcategory || 'other'}</span>
</div>
</button>
`).join('')}
</div>
</div>
`;
});
this.showModal(`Exécuter un Playbook sur "${currentGroup === 'all' ? 'Tous les hôtes' : currentGroup}"`, `
<div class="space-y-4">
<div class="p-3 bg-purple-900/30 border border-purple-600 rounded-lg text-sm">
<i class="fas fa-info-circle text-purple-400 mr-2"></i>
Sélectionnez un playbook à exécuter sur <strong>${currentGroup === 'all' ? 'tous les hôtes' : 'le groupe ' + currentGroup}</strong>
</div>
<div class="p-3 bg-blue-900/30 border border-blue-600 rounded-lg text-sm">
<i class="fas fa-filter text-blue-400 mr-2"></i>
Seuls les playbooks compatibles avec ce ${currentGroup === 'all' ? 'groupe' : 'groupe'} sont affichés (${compatiblePlaybooks.length} disponible${compatiblePlaybooks.length > 1 ? 's' : ''})
</div>
<div class="max-h-96 overflow-y-auto">
${playbooksHtml || '<p class="text-gray-500 text-center py-4">Aucun playbook disponible</p>'}
</div>
<button onclick="dashboard.closeModal()" class="w-full px-4 py-2 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500">
Annuler
</button>
</div>
`);
}
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}`, `
<div class="space-y-4">
<div class="p-4 ${statusColor} border rounded-lg">
<div class="flex items-center space-x-3">
<i class="fas ${statusIcon} text-2xl"></i>
<div>
<p class="font-semibold">${result.success ? 'Exécution réussie' : 'Échec de l\'exécution'}</p>
<p class="text-sm text-gray-400">Durée: ${result.execution_time}s</p>
</div>
</div>
</div>
<div class="p-4 bg-gray-800 rounded-lg max-h-64 overflow-auto">
<pre class="text-xs text-gray-300 whitespace-pre-wrap font-mono">${this.escapeHtml(result.stdout || '(pas de sortie)')}</pre>
</div>
${result.stderr ? `
<div class="p-4 bg-red-900/20 rounded-lg max-h-32 overflow-auto">
<h4 class="text-sm font-semibold text-red-400 mb-2">Erreurs:</h4>
<pre class="text-xs text-red-300 whitespace-pre-wrap font-mono">${this.escapeHtml(result.stderr)}</pre>
</div>
` : ''}
<button onclick="dashboard.closeModal()" class="w-full btn-primary">Fermer</button>
</div>
`);
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 =>
`<option value="${cat}" ${this.currentCategoryFilter === cat ? 'selected' : ''}>${cat}</option>`
).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 =>
`<option value="${sub}" ${this.currentSubcategoryFilter === sub ? 'selected' : ''}>${sub}</option>`
).join('');
}
// Générer les options de target (groupes + hôtes)
const groupOptions = this.ansibleGroups.map(g =>
`<option value="${g}" ${this.currentTargetFilter === g ? 'selected' : ''}>${g} (groupe)</option>`
).join('');
const hostOptions = this.hosts.map(h =>
`<option value="${h.name}" ${this.currentTargetFilter === h.name ? 'selected' : ''}>${h.name}</option>`
).join('');
// Catégories dynamiques pour le filtre
const taskCategories = ['Playbook', 'Ad-hoc', 'Autre'];
const taskCategoryOptions = taskCategories.map(cat =>
`<option value="${cat}" ${this.currentCategoryFilter === cat ? 'selected' : ''}>${cat}</option>`
).join('');
// Types de source pour le filtre
const sourceTypes = [
{ value: 'scheduled', label: 'Planifiés' },
{ value: 'manual', label: 'Manuels' },
{ value: 'adhoc', label: 'Ad-hoc' }
];
const sourceTypeOptions = sourceTypes.map(st =>
`<option value="${st.value}" ${this.currentSourceTypeFilter === st.value ? 'selected' : ''}>${st.label}</option>`
).join('');
// Vérifier si des filtres sont actifs
const hasActiveFilters = (this.currentTargetFilter && this.currentTargetFilter !== 'all') ||
(this.currentCategoryFilter && this.currentCategoryFilter !== 'all') ||
(this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') ||
(this.currentHourStart || this.currentHourEnd);
// Labels pour les types de source
const sourceTypeLabels = { scheduled: 'Planifiés', manual: 'Manuels', adhoc: 'Ad-hoc' };
// Générer les badges de filtres actifs
const activeFiltersHtml = hasActiveFilters ? `
<div class="flex flex-wrap items-center gap-2 mt-2 pt-2 border-t border-gray-700">
<span class="text-xs text-gray-500">Filtres actifs:</span>
${this.currentTargetFilter && this.currentTargetFilter !== 'all' ? `
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-purple-600/30 text-purple-300 border border-purple-500/50">
<i class="fas fa-server mr-1"></i>${this.escapeHtml(this.currentTargetFilter)}
<button onclick="dashboard.clearTargetFilter()" class="ml-1 hover:text-white" title="Supprimer ce filtre">
<i class="fas fa-times"></i>
</button>
</span>
` : ''}
${this.currentCategoryFilter && this.currentCategoryFilter !== 'all' ? `
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-600/30 text-blue-300 border border-blue-500/50">
<i class="fas fa-folder mr-1"></i>${this.escapeHtml(this.currentCategoryFilter)}
<button onclick="dashboard.clearCategoryFilter()" class="ml-1 hover:text-white" title="Supprimer ce filtre">
<i class="fas fa-times"></i>
</button>
</span>
` : ''}
${this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all' ? `
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-green-600/30 text-green-300 border border-green-500/50">
<i class="fas fa-clock mr-1"></i>${sourceTypeLabels[this.currentSourceTypeFilter] || this.currentSourceTypeFilter}
<button onclick="dashboard.clearSourceTypeFilter()" class="ml-1 hover:text-white" title="Supprimer ce filtre">
<i class="fas fa-times"></i>
</button>
</span>
` : ''}
${this.currentHourStart || this.currentHourEnd ? `
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-yellow-600/30 text-yellow-300 border border-yellow-500/50">
<i class="fas fa-clock mr-1"></i>${this.currentHourStart || '00:00'} - ${this.currentHourEnd || '23:59'}
<button onclick="dashboard.clearHourFilter()" class="ml-1 hover:text-white" title="Supprimer ce filtre">
<i class="fas fa-times"></i>
</button>
</span>
` : ''}
<button onclick="dashboard.clearAllTaskFilters()" class="text-xs text-gray-400 hover:text-white transition-colors">
<i class="fas fa-times-circle mr-1"></i>Tout effacer
</button>
</div>
` : '';
// Header avec filtres de catégorie, target et bouton console
const headerHtml = `
<div class="flex flex-col gap-2 mb-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-400">
<i class="fas fa-file-alt mr-2"></i>${filteredLogs.length} log(s)
</span>
${runningTasks.length > 0 ? `
<span class="px-2 py-1 bg-blue-600 text-xs rounded-full animate-pulse">
${runningTasks.length} en cours
</span>
` : ''}
</div>
<div class="flex flex-wrap items-center gap-2">
<select id="task-target-filter" onchange="dashboard.filterTasksByTarget(this.value)"
class="px-2 py-1.5 bg-gray-700 border border-gray-600 rounded text-xs">
<option value="all" ${this.currentTargetFilter === 'all' || !this.currentTargetFilter ? 'selected' : ''}>Toutes cibles</option>
<optgroup label="Groupes">
${groupOptions}
</optgroup>
<optgroup label="Hôtes">
${hostOptions}
</optgroup>
</select>
<select id="task-category-filter" onchange="dashboard.filterTasksByCategory(this.value)"
class="px-2 py-1.5 bg-gray-700 border border-gray-600 rounded text-xs">
<option value="all" ${this.currentCategoryFilter === 'all' || !this.currentCategoryFilter ? 'selected' : ''}>Toutes catégories</option>
${taskCategoryOptions}
</select>
<select id="task-source-type-filter" onchange="dashboard.filterTasksBySourceType(this.value)"
class="px-2 py-1.5 bg-gray-700 border border-gray-600 rounded text-xs">
<option value="all" ${this.currentSourceTypeFilter === 'all' || !this.currentSourceTypeFilter ? 'selected' : ''}>Tous types</option>
${sourceTypeOptions}
</select>
<button onclick="dashboard.showAdHocConsole()" class="px-3 py-1.5 bg-purple-600 text-xs rounded hover:bg-purple-500 transition-colors">
<i class="fas fa-terminal mr-1"></i>Console Ad-Hoc
</button>
</div>
</div>
${activeFiltersHtml}
</div>
`;
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 = '<h4 class="text-sm font-semibold text-blue-400 mb-2"><i class="fas fa-spinner fa-spin mr-2"></i>En cours</h4>';
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 = '<h4 class="text-sm font-semibold text-gray-400 mb-2 mt-4"><i class="fas fa-history mr-2"></i>Historique des tâches</h4>';
// Afficher tous les logs chargés (pagination côté serveur)
filteredLogs.forEach(log => {
logsSection.appendChild(this.createTaskLogCard(log));
});
container.appendChild(logsSection);
// Afficher la pagination si nécessaire (basée sur la pagination serveur)
const paginationEl = document.getElementById('tasks-pagination');
if (paginationEl) {
if (this.tasksHasMore) {
paginationEl.classList.remove('hidden');
const remaining = this.tasksTotalCount - filteredLogs.length;
paginationEl.innerHTML = `
<button onclick="dashboard.loadMoreTasks()" class="px-6 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 text-sm">
<i class="fas fa-chevron-down mr-2"></i>Charger plus (${remaining} restantes)
</button>
`;
} else {
paginationEl.classList.add('hidden');
}
}
} else if (runningTasks.length === 0) {
container.innerHTML += `
<div class="text-center text-gray-500 py-8">
<i class="fas fa-folder-open text-2xl mb-2"></i>
<p>Aucune tâche trouvée</p>
<p class="text-sm">Utilisez "Actions Rapides" ou la Console pour lancer une commande</p>
</div>
`;
}
}
async loadMoreTasks() {
// Charger plus de tâches depuis le serveur (pagination côté serveur)
const params = new URLSearchParams();
if (this.currentStatusFilter && this.currentStatusFilter !== 'all') {
params.append('status', this.currentStatusFilter);
}
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
const firstDate = this.parseDateKey(this.selectedTaskDates[0]);
params.append('year', String(firstDate.getFullYear()));
params.append('month', String(firstDate.getMonth() + 1).padStart(2, '0'));
params.append('day', String(firstDate.getDate()).padStart(2, '0'));
} else {
if (this.currentDateFilter.year) params.append('year', this.currentDateFilter.year);
if (this.currentDateFilter.month) params.append('month', this.currentDateFilter.month);
if (this.currentDateFilter.day) params.append('day', this.currentDateFilter.day);
}
if (this.currentHourStart) params.append('hour_start', this.currentHourStart);
if (this.currentHourEnd) params.append('hour_end', this.currentHourEnd);
if (this.currentTargetFilter && this.currentTargetFilter !== 'all') {
params.append('target', this.currentTargetFilter);
}
if (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') {
params.append('category', this.currentCategoryFilter);
}
if (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') {
params.append('source_type', this.currentSourceTypeFilter);
}
// Pagination: charger la page suivante
params.append('limit', this.tasksPerPage);
params.append('offset', this.taskLogs.length);
try {
const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`);
const newLogs = result.logs || [];
// Ajouter les nouveaux logs à la liste existante
this.taskLogs = [...this.taskLogs, ...newLogs];
this.tasksTotalCount = result.total_count || this.tasksTotalCount;
this.tasksHasMore = result.has_more || false;
this.tasksDisplayedCount = this.taskLogs.length;
// Récupérer la section des logs
const logsSection = document.getElementById('task-logs-section');
if (!logsSection) {
this.renderTasks();
return;
}
// Ajouter les nouvelles tâches au DOM
for (const log of newLogs) {
logsSection.appendChild(this.createTaskLogCard(log));
}
// Mettre à jour le bouton de pagination
const paginationEl = document.getElementById('tasks-pagination');
if (paginationEl) {
if (this.tasksHasMore) {
const remaining = this.tasksTotalCount - this.taskLogs.length;
paginationEl.innerHTML = `
<button onclick="dashboard.loadMoreTasks()" class="px-6 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 text-sm">
<i class="fas fa-chevron-down mr-2"></i>Charger plus (${remaining} restantes)
</button>
`;
} else {
paginationEl.classList.add('hidden');
}
}
} catch (error) {
console.error('Erreur chargement logs supplémentaires:', error);
}
}
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': '<i class="fas fa-check-circle text-green-500"></i>',
'failed': '<i class="fas fa-times-circle text-red-500"></i>',
'running': '<i class="fas fa-cog fa-spin text-blue-500"></i>',
'pending': '<i class="fas fa-clock text-yellow-500"></i>'
};
// 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
? `<div class="flex flex-wrap gap-1 mt-2">
${log.hosts.slice(0, 8).map(host => `
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs border border-green-600/50 bg-green-600/20 text-green-400 cursor-pointer hover:bg-green-600/30 transition-colors"
onclick="event.stopPropagation(); dashboard.filterByHost('${this.escapeHtml(host)}')" title="Filtrer par ${this.escapeHtml(host)}">
<i class="fas fa-circle text-green-500 mr-1" style="font-size: 6px;"></i>
${this.escapeHtml(host)}
</span>
`).join('')}
${log.hosts.length > 8 ? `<span class="text-xs text-gray-500">+${log.hosts.length - 8} autres</span>` : ''}
</div>`
: '';
// Badge de catégorie
const categoryBadge = log.category
? `<span class="px-2 py-0.5 rounded text-xs cursor-pointer hover:opacity-80 transition-opacity ${
log.category === 'Playbook' ? 'bg-purple-600/30 text-purple-300 border border-purple-500/50' :
log.category === 'Ad-hoc' ? 'bg-blue-600/30 text-blue-300 border border-blue-500/50' :
'bg-gray-600/30 text-gray-300 border border-gray-500/50'
}" onclick="event.stopPropagation(); dashboard.filterByCategory('${this.escapeHtml(log.category)}')" title="Filtrer par catégorie">
${this.escapeHtml(log.category)}${log.subcategory ? ` / ${this.escapeHtml(log.subcategory)}` : ''}
</span>`
: '';
// Cible cliquable
const targetHtml = log.target
? `<span class="text-purple-400 cursor-pointer hover:text-purple-300 hover:underline transition-colors"
onclick="event.stopPropagation(); dashboard.filterByTarget('${this.escapeHtml(log.target)}')"
title="Filtrer par ${this.escapeHtml(log.target)}">
${this.escapeHtml(log.target)}
</span>`
: '';
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 = `
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<!-- Ligne 1: Icône, Nom, Status, Catégorie -->
<div class="flex items-center flex-wrap gap-2 mb-1">
${statusIcons[log.status] || '<i class="fas fa-question-circle text-gray-500"></i>'}
<h4 class="font-semibold text-white text-sm truncate">${this.escapeHtml(log.task_name)}</h4>
${this.getStatusBadge(log.status)}
${categoryBadge}
</div>
<!-- Ligne 2: Date, Cible, Heures, Durée -->
<div class="ml-6 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-400">
<span><i class="fas fa-calendar mr-1"></i>${log.date}</span>
${log.target ? `<span><i class="fas fa-server mr-1"></i>${targetHtml}</span>` : ''}
${startTime ? `<span><i class="fas fa-play mr-1 text-green-500"></i>${startTime}</span>` : ''}
${endTime ? `<span><i class="fas fa-stop mr-1 text-red-500"></i>${endTime}</span>` : ''}
${duration ? `<span class="text-yellow-400"><i class="fas fa-clock mr-1"></i>${duration}</span>` : ''}
</div>
<!-- Ligne 3: Hôtes -->
${hostsHtml}
</div>
<!-- Boutons horizontaux -->
<div class="flex items-center gap-1 flex-shrink-0">
<button class="p-1.5 bg-gray-700 rounded hover:bg-gray-600 transition-colors"
onclick="event.stopPropagation(); dashboard.viewTaskLogContent('${log.id}')" title="Voir le contenu">
<i class="fas fa-eye text-gray-300 text-xs"></i>
</button>
<button class="p-1.5 bg-blue-700 rounded hover:bg-blue-600 transition-colors"
onclick="event.stopPropagation(); dashboard.downloadTaskLog('${log.path}')" title="Télécharger">
<i class="fas fa-download text-white text-xs"></i>
</button>
<button class="p-1.5 bg-red-700 rounded hover:bg-red-600 transition-colors"
onclick="event.stopPropagation(); dashboard.deleteTaskLog('${log.id}', '${this.escapeHtml(log.filename)}')" title="Supprimer le log">
<i class="fas fa-trash text-white text-xs"></i>
</button>
</div>
</div>
`;
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, `
<div class="playbook-execution-viewer" style="max-width: 100%;">
${adHocView}
<div class="mt-4">
<button onclick="dashboard.closeModal()" class="w-full px-4 py-3 bg-purple-600 rounded-xl hover:bg-purple-500 transition-colors font-medium">
Fermer
</button>
</div>
</div>
`);
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 = `
<div class="mb-3">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-medium text-gray-400 uppercase tracking-wide">
<i class="fas fa-server mr-1"></i>Sortie par hôte
</span>
<span class="text-xs text-gray-500">
(${totalHosts} hôtes: <span class="text-green-400">${successCount} OK</span>${failedCount > 0 ? `, <span class="text-red-400">${failedCount} échec</span>` : ''})
</span>
<button type="button" onclick="dashboard.showAllTaskLogHostsOutput()"
class="ml-auto text-xs text-purple-400 hover:text-purple-300 transition-colors">
<i class="fas fa-eye mr-1"></i>Voir tout
</button>
</div>
<div class="flex flex-wrap gap-1.5">
${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 `
<button type="button"
onclick="dashboard.switchTaskLogHostTab(${index})"
data-tasklog-host-tab="${index}"
class="tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border ${index === 0 ? hostStatusColor + ' text-white' : 'bg-gray-800 text-gray-400 hover:text-white border-gray-700'}">
<i class="fas ${hostStatusIcon} text-[10px]"></i>
<span class="truncate max-w-[140px]">${this.escapeHtml(host.hostname)}</span>
</button>
`;
}).join('')}
</div>
</div>
`;
}
// Contenu du modal amélioré
const modalContent = `
<div class="task-log-viewer">
<!-- Header du résultat -->
<div class="flex items-center justify-between p-4 rounded-t-xl mb-0 ${isSuccess ? 'bg-green-900/30 border-b border-green-800/50' : 'bg-red-900/30 border-b border-red-800/50'}">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center ${isSuccess ? 'bg-green-900/50' : 'bg-red-900/50'}">
<i class="fas ${status.icon} text-lg ${isSuccess ? 'text-green-400' : 'text-red-400'}"></i>
</div>
<div>
<h4 class="font-semibold text-white">Résultat d'exécution</h4>
<p class="text-sm ${isSuccess ? 'text-green-400' : 'text-red-400'}">
${status.text} • Cible: <span class="text-gray-300">${this.escapeHtml(result.log.target || parsed.target || 'N/A')}</span>
</p>
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5 px-3 py-1.5 bg-gray-800/80 rounded-lg text-xs">
<i class="fas fa-clock text-gray-400"></i>
<span class="text-white font-medium">${parsed.duration || 'N/A'}</span>
</div>
${parsed.returnCode !== undefined ? `
<div class="flex items-center gap-1.5 px-3 py-1.5 bg-gray-800/80 rounded-lg text-xs">
<i class="fas fa-hashtag text-gray-400"></i>
<span class="text-white font-medium">Code: ${parsed.returnCode}</span>
</div>
` : ''}
</div>
</div>
<!-- Corps du résultat -->
<div class="bg-gray-900/80 rounded-b-xl border border-gray-800 border-t-0">
<div class="p-4">
<!-- Onglets des hôtes -->
${hostTabsHtml}
<!-- Zone de sortie terminal -->
<div class="relative">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">
<i class="fas fa-terminal mr-1"></i>Sortie
</span>
<button onclick="dashboard.copyTaskLogOutput()" class="text-xs text-gray-400 hover:text-white transition-colors">
<i class="fas fa-copy mr-1"></i>Copier
</button>
</div>
<pre id="tasklog-output" class="text-sm bg-black/60 p-4 rounded-lg overflow-auto font-mono text-gray-300 leading-relaxed border border-gray-800" style="max-height: 350px;">${this.formatAnsibleOutput(hostOutputs.length > 0 ? hostOutputs[0].output : parsed.output, isSuccess)}</pre>
</div>
<!-- Section erreurs si présentes -->
${parsed.error ? `
<div class="mt-4">
<details class="group" open>
<summary class="flex items-center gap-2 mb-2 cursor-pointer list-none">
<span class="text-xs font-medium text-red-400 uppercase tracking-wide">
<i class="fas fa-exclamation-triangle mr-1"></i>Erreurs
<i class="fas fa-chevron-down ml-1 text-[10px] transition-transform group-open:rotate-180"></i>
</span>
</summary>
<pre class="text-sm bg-red-950/30 p-4 rounded-lg overflow-auto font-mono text-red-300 leading-relaxed border border-red-900/50" style="max-height: 150px;">${this.escapeHtml(parsed.error)}</pre>
</details>
</div>
` : ''}
</div>
</div>
<!-- Bouton fermer -->
<div class="mt-4">
<button onclick="dashboard.closeModal()" class="w-full px-4 py-3 bg-purple-600 rounded-xl hover:bg-purple-500 transition-colors font-medium">
Fermer
</button>
</div>
</div>
`;
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, `
<div class="playbook-execution-viewer" style="max-width: 100%;">
${structuredView}
<div class="mt-4">
<button onclick="dashboard.closeModal()" class="w-full px-4 py-3 bg-purple-600 rounded-xl hover:bg-purple-500 transition-colors font-medium">
Fermer
</button>
</div>
</div>
`);
// 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 = '<i class="fas fa-times-circle text-red-400"></i>';
} else if (hasChanges) {
statusClass = 'border-yellow-500/50 bg-yellow-900/20';
statusIcon = '<i class="fas fa-exchange-alt text-yellow-400"></i>';
} else {
statusClass = 'border-green-500/50 bg-green-900/20';
statusIcon = '<i class="fas fa-check-circle text-green-400"></i>';
}
const hostStatus = isFailed ? 'failed' : (hasChanges ? 'changed' : 'ok');
return `
<div class="host-card-item p-3 rounded-lg border ${statusClass} hover:scale-[1.02] transition-all cursor-pointer"
data-hostname="${this.escapeHtml(host.hostname)}"
data-status="${hostStatus}"
onclick="dashboard.showAdHocHostDetails('${this.escapeHtml(host.hostname)}')">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
${statusIcon}
<span class="font-medium text-white text-sm truncate max-w-[120px]" title="${this.escapeHtml(host.hostname)}">
${this.escapeHtml(host.hostname)}
</span>
</div>
<span class="text-xs px-2 py-0.5 rounded ${isFailed ? 'bg-red-900/50 text-red-300' : hasChanges ? 'bg-yellow-900/50 text-yellow-300' : 'bg-green-900/50 text-green-300'}">
${host.status.toUpperCase()}
</span>
</div>
<div class="text-xs text-gray-500 truncate" title="${this.escapeHtml(host.output.substring(0, 100))}">
${this.escapeHtml(host.output.substring(0, 60))}${host.output.length > 60 ? '...' : ''}
</div>
</div>
`;
}).join('');
return `
<div class="ansible-viewer" data-view-type="adhoc">
<!-- En-tête Contextuel -->
<div class="execution-header p-4 rounded-xl mb-4 ${isSuccess ? 'bg-gradient-to-r from-green-900/40 to-emerald-900/30 border border-green-700/50' : 'bg-gradient-to-r from-red-900/40 to-orange-900/30 border border-red-700/50'}">
<div class="flex items-start justify-between">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center ${isSuccess ? 'bg-green-600/30' : 'bg-red-600/30'} backdrop-blur-sm">
<i class="fas fa-terminal text-2xl ${isSuccess ? 'text-green-400' : 'text-red-400'}"></i>
</div>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="px-2 py-0.5 text-xs font-medium rounded-full ${isSuccess ? 'bg-green-600/40 text-green-300' : 'bg-red-600/40 text-red-300'}">
Commande Ad-Hoc
</span>
</div>
<h3 class="text-xl font-bold text-white mb-1">${this.escapeHtml(metadata.taskName || 'Commande Ansible')}</h3>
<p class="text-sm text-gray-400">
<i class="fas fa-server mr-1"></i>${totalHosts} hôte(s)
<span class="mx-2">•</span>
<i class="fas fa-bullseye mr-1"></i>Cible: ${this.escapeHtml(metadata.target)}
<span class="mx-2">•</span>
<i class="fas fa-clock mr-1"></i>${metadata.duration}
</p>
</div>
</div>
<div class="flex flex-col items-end gap-2">
<div class="flex items-center gap-2">
<span class="text-3xl font-bold ${isSuccess ? 'text-green-400' : 'text-red-400'}">
${isSuccess ? '✓' : '✗'}
</span>
<span class="text-lg font-semibold ${isSuccess ? 'text-green-400' : 'text-red-400'}">
${isSuccess ? 'SUCCESS' : 'FAILED'}
</span>
</div>
<div class="text-xs text-gray-500">${metadata.date || ''}</div>
${metadata.returnCode !== undefined ? `
<div class="flex items-center gap-1.5 px-2 py-1 bg-gray-800/80 rounded text-xs">
<i class="fas fa-hashtag text-gray-400"></i>
<span class="text-white">Code: ${metadata.returnCode}</span>
</div>
` : ''}
</div>
</div>
</div>
<!-- Statistiques -->
<div class="grid grid-cols-4 gap-3 mb-4">
<div class="stat-card p-3 rounded-xl bg-green-900/20 border border-green-700/30">
<div class="flex items-center gap-2 mb-1">
<i class="fas fa-check text-green-400 text-xs"></i>
<span class="text-xs text-gray-400 uppercase">OK</span>
</div>
<div class="text-2xl font-bold text-green-400">${successCount}</div>
</div>
<div class="stat-card p-3 rounded-xl bg-yellow-900/20 border border-yellow-700/30">
<div class="flex items-center gap-2 mb-1">
<i class="fas fa-exchange-alt text-yellow-400 text-xs"></i>
<span class="text-xs text-gray-400 uppercase">Changed</span>
</div>
<div class="text-2xl font-bold text-yellow-400">${hostOutputs.filter(h => h.status === 'changed').length}</div>
</div>
<div class="stat-card p-3 rounded-xl bg-red-900/20 border border-red-700/30">
<div class="flex items-center gap-2 mb-1">
<i class="fas fa-times text-red-400 text-xs"></i>
<span class="text-xs text-gray-400 uppercase">Failed</span>
</div>
<div class="text-2xl font-bold text-red-400">${failedCount}</div>
</div>
<div class="stat-card p-3 rounded-xl bg-purple-900/20 border border-purple-700/30">
<div class="flex items-center gap-2 mb-1">
<i class="fas fa-percentage text-purple-400 text-xs"></i>
<span class="text-xs text-gray-400 uppercase">Success Rate</span>
</div>
<div class="text-2xl font-bold text-purple-400">${successRate}%</div>
</div>
</div>
<!-- État des Hôtes -->
<div class="host-status-section mb-4">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-300 uppercase tracking-wide">
<i class="fas fa-server mr-2 text-purple-400"></i>État des Hôtes
</h4>
<div class="flex items-center gap-2">
<button type="button" onclick="dashboard.filterAdHocViewByStatus('all')"
class="av-filter-btn active px-2 py-1 text-xs rounded bg-purple-600/50 text-white" data-filter="all">
Tous
</button>
<button type="button" onclick="dashboard.filterAdHocViewByStatus('ok')"
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="ok">
<i class="fas fa-check text-green-400 mr-1"></i>OK
</button>
<button type="button" onclick="dashboard.filterAdHocViewByStatus('changed')"
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="changed">
<i class="fas fa-exchange-alt text-yellow-400 mr-1"></i>Changed
</button>
<button type="button" onclick="dashboard.filterAdHocViewByStatus('failed')"
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="failed">
<i class="fas fa-times text-red-400 mr-1"></i>Failed
</button>
</div>
</div>
<div class="adhoc-host-cards-grid grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
${hostCardsHtml}
</div>
</div>
${metadata.error ? `
<!-- Section erreurs -->
<div class="mb-4">
<details class="group" open>
<summary class="flex items-center gap-2 p-3 bg-red-900/30 rounded-lg cursor-pointer hover:bg-red-900/40 transition-colors">
<i class="fas fa-exclamation-triangle text-red-400"></i>
<span class="text-sm font-medium text-red-300">Erreurs détectées</span>
<i class="fas fa-chevron-right text-xs text-red-500 transition-transform group-open:rotate-90 ml-auto"></i>
</summary>
<pre class="mt-2 text-xs bg-red-950/30 p-4 rounded-lg overflow-auto font-mono text-red-300 leading-relaxed border border-red-900/50" style="max-height: 150px;">${this.escapeHtml(metadata.error)}</pre>
</details>
</div>
` : ''}
</div>
`;
}
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 = '<i class="fas fa-times-circle text-red-400"></i>';
statusText = '<span class="text-red-400">FAILED</span>';
} else if (hasChanges) {
statusClass = 'bg-yellow-900/30 border-yellow-700/50';
statusIcon = '<i class="fas fa-exchange-alt text-yellow-400"></i>';
statusText = '<span class="text-yellow-400">CHANGED</span>';
} else {
statusClass = 'bg-green-900/30 border-green-700/50';
statusIcon = '<i class="fas fa-check-circle text-green-400"></i>';
statusText = '<span class="text-green-400">OK</span>';
}
const content = `
<div class="host-details-modal">
<div class="flex items-center justify-between mb-2">
<button type="button" onclick="dashboard.returnToAdHocView()" class="inline-flex items-center gap-2 text-xs px-2 py-1 rounded bg-gray-800 text-gray-300 hover:bg-gray-700 transition-colors">
<i class="fas fa-arrow-left text-gray-400"></i>
<span>Retour au résumé</span>
</button>
<div class="text-xs text-gray-500 hidden sm:flex items-center gap-1">
<span class="text-gray-400">Résumé</span>
<span class="text-gray-600"></span>
<span class="text-gray-200 truncate max-w-[180px]">${this.escapeHtml(hostname)}</span>
</div>
</div>
<div class="flex items-center gap-3 mb-4 p-3 ${statusClass} rounded-lg border">
<div class="w-10 h-10 rounded-lg bg-purple-600/30 flex items-center justify-center">
<i class="fas fa-server text-purple-400"></i>
</div>
<div class="flex-1">
<h4 class="font-semibold text-white">${this.escapeHtml(hostname)}</h4>
<div class="text-xs text-gray-400">
Statut: ${statusText}
</div>
</div>
${statusIcon}
</div>
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">
<i class="fas fa-terminal mr-1"></i>Sortie
</span>
<button onclick="dashboard.copyToClipboard(\`${this.escapeHtml(host.output).replace(/`/g, '\\`')}\`)" class="text-xs text-gray-400 hover:text-white transition-colors">
<i class="fas fa-copy mr-1"></i>Copier
</button>
</div>
<pre class="text-sm bg-black/60 p-4 rounded-lg overflow-auto font-mono text-gray-300 leading-relaxed border border-gray-800" style="max-height: 400px;">${this.formatAnsibleOutput(host.output, !isFailed)}</pre>
</div>
<button onclick="dashboard.closeModal()" class="w-full px-4 py-3 bg-purple-600 rounded-xl hover:bg-purple-500 transition-colors font-medium">
Fermer
</button>
</div>
`;
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, `
<div class="playbook-execution-viewer" style="max-width: 100%;">
${adHocView}
<div class="mt-4">
<button onclick="dashboard.closeModal()" class="w-full px-4 py-3 bg-purple-600 rounded-xl hover:bg-purple-500 transition-colors font-medium">
Fermer
</button>
</div>
</div>
`);
}
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 `
<div class="ansible-viewer" data-playbook-type="${playbookType}">
<!-- En-tête Contextuel -->
<div class="execution-header p-4 rounded-xl mb-4 ${isSuccess ? 'bg-gradient-to-r from-green-900/40 to-emerald-900/30 border border-green-700/50' : 'bg-gradient-to-r from-red-900/40 to-orange-900/30 border border-red-700/50'}">
<div class="flex items-start justify-between">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl flex items-center justify-center ${isSuccess ? 'bg-green-600/30' : 'bg-red-600/30'} backdrop-blur-sm">
<i class="fas ${typeIcons[playbookType]} text-2xl ${isSuccess ? 'text-green-400' : 'text-red-400'}"></i>
</div>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="px-2 py-0.5 text-xs font-medium rounded-full ${isSuccess ? 'bg-green-600/40 text-green-300' : 'bg-red-600/40 text-red-300'}">
${typeLabels[playbookType]}
</span>
${hasChanges ? '<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-600/40 text-yellow-300">Changes Applied</span>' : ''}
</div>
<h3 class="text-xl font-bold text-white mb-1">${this.escapeHtml(parsedOutput.metadata.playbookName || 'Ansible Playbook')}</h3>
<p class="text-sm text-gray-400">
<i class="fas fa-server mr-1"></i>${parsedOutput.stats.totalHosts} hôte(s)
<span class="mx-2">•</span>
<i class="fas fa-tasks mr-1"></i>${parsedOutput.stats.totalTasks} tâche(s)
<span class="mx-2">•</span>
<i class="fas fa-clock mr-1"></i>${metadata.duration || 'N/A'}
</p>
</div>
</div>
<div class="flex flex-col items-end gap-2">
<div class="flex items-center gap-2">
<span class="text-3xl font-bold ${isSuccess ? 'text-green-400' : 'text-red-400'}">
${isSuccess ? '✓' : '✗'}
</span>
<span class="text-lg font-semibold ${isSuccess ? 'text-green-400' : 'text-red-400'}">
${isSuccess ? 'SUCCESS' : 'FAILED'}
</span>
</div>
<div class="text-xs text-gray-500">${metadata.date || ''}</div>
</div>
</div>
</div>
<!-- Statistiques Visuelles -->
${statsHtml}
<!-- Cartes d'état des hôtes -->
<div class="mb-4">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-300 uppercase tracking-wide">
<i class="fas fa-server mr-2 text-purple-400"></i>État des Hôtes
</h4>
<div class="flex items-center gap-2">
<button type="button" onclick="dashboard.filterAnsibleViewByStatus('all')"
class="av-filter-btn active px-2 py-1 text-xs rounded bg-gray-700 text-gray-300 hover:bg-gray-600" data-filter="all">
Tous
</button>
<button type="button" onclick="dashboard.filterAnsibleViewByStatus('ok')"
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="ok">
<i class="fas fa-check text-green-400 mr-1"></i>OK
</button>
<button type="button" onclick="dashboard.filterAnsibleViewByStatus('changed')"
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="changed">
<i class="fas fa-exchange-alt text-yellow-400 mr-1"></i>Changed
</button>
<button type="button" onclick="dashboard.filterAnsibleViewByStatus('failed')"
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="failed">
<i class="fas fa-times text-red-400 mr-1"></i>Failed
</button>
</div>
</div>
<div class="host-cards-grid grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
${hostCardsHtml}
</div>
</div>
<!-- Arborescence des Tâches -->
<div class="task-hierarchy-section">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-300 uppercase tracking-wide">
<i class="fas fa-sitemap mr-2 text-blue-400"></i>Hiérarchie des Tâches
</h4>
<div class="flex items-center gap-2">
<button type="button" onclick="dashboard.expandAllTasks()" class="px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600 hover:text-white">
<i class="fas fa-expand-alt mr-1"></i>Tout déplier
</button>
<button type="button" onclick="dashboard.collapseAllTasks()" class="px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600 hover:text-white">
<i class="fas fa-compress-alt mr-1"></i>Tout replier
</button>
</div>
</div>
<div class="task-tree bg-gray-900/50 rounded-xl border border-gray-800">
${taskTreeHtml}
</div>
</div>
<!-- Vue brute (repliable) -->
<details class="mt-4 group">
<summary class="flex items-center gap-2 p-3 bg-gray-800/50 rounded-lg cursor-pointer hover:bg-gray-800 transition-colors">
<i class="fas fa-chevron-right text-xs text-gray-500 transition-transform group-open:rotate-90"></i>
<span class="text-sm text-gray-400">Afficher la sortie brute</span>
</summary>
<pre id="ansible-raw-output" class="mt-2 text-xs bg-black/60 p-4 rounded-lg overflow-auto font-mono text-gray-400 leading-relaxed border border-gray-800" style="max-height: 300px;"></pre>
</details>
</div>
`;
}
renderHostStatusCards(parsedOutput) {
const hosts = Object.entries(parsedOutput.recap);
if (hosts.length === 0) {
return '<div class="text-gray-500 text-sm p-4">Aucun hôte détecté</div>';
}
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 = '<i class="fas fa-times-circle text-red-400"></i>';
statusBg = 'bg-red-500';
} else if (hasChanges) {
statusClass = 'border-yellow-500/50 bg-yellow-900/20';
statusIcon = '<i class="fas fa-exchange-alt text-yellow-400"></i>';
statusBg = 'bg-yellow-500';
} else {
statusClass = 'border-green-500/50 bg-green-900/20';
statusIcon = '<i class="fas fa-check-circle text-green-400"></i>';
statusBg = 'bg-green-500';
}
const hostStatus = isFailed ? 'failed' : (hasChanges ? 'changed' : 'ok');
return `
<div class="host-card-item p-3 rounded-lg border ${statusClass} hover:scale-[1.02] transition-all cursor-pointer"
data-hostname="${this.escapeHtml(hostname)}"
data-status="${hostStatus}"
onclick="dashboard.showHostDetails('${this.escapeHtml(hostname)}')">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
${statusIcon}
<span class="font-medium text-white text-sm truncate max-w-[120px]" title="${this.escapeHtml(hostname)}">
${this.escapeHtml(hostname)}
</span>
</div>
<span class="text-xs ${isFailed ? 'text-red-400' : hasChanges ? 'text-yellow-400' : 'text-green-400'} font-semibold">
${successPercent}%
</span>
</div>
<div class="flex gap-1 h-1.5 rounded-full overflow-hidden bg-gray-800">
${stats.ok > 0 ? `<div class="bg-green-500" style="width: ${(stats.ok/total)*100}%" title="OK: ${stats.ok}"></div>` : ''}
${stats.changed > 0 ? `<div class="bg-yellow-500" style="width: ${(stats.changed/total)*100}%" title="Changed: ${stats.changed}"></div>` : ''}
${stats.skipped > 0 ? `<div class="bg-gray-500" style="width: ${(stats.skipped/total)*100}%" title="Skipped: ${stats.skipped}"></div>` : ''}
${stats.failed > 0 ? `<div class="bg-red-500" style="width: ${(stats.failed/total)*100}%" title="Failed: ${stats.failed}"></div>` : ''}
${stats.unreachable > 0 ? `<div class="bg-orange-500" style="width: ${(stats.unreachable/total)*100}%" title="Unreachable: ${stats.unreachable}"></div>` : ''}
</div>
<div class="flex justify-between mt-2 text-[10px] text-gray-500">
<span><span class="text-green-400">${stats.ok}</span> ok</span>
<span><span class="text-yellow-400">${stats.changed}</span> chg</span>
<span><span class="text-red-400">${stats.failed}</span> fail</span>
</div>
</div>
`;
}).join('');
}
renderTaskHierarchy(parsedOutput) {
if (parsedOutput.plays.length === 0) {
return '<div class="text-gray-500 text-sm p-4">Aucune tâche détectée</div>';
}
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
? '<i class="fas fa-times-circle text-red-400"></i>'
: '<i class="fas fa-check-circle text-green-400"></i>';
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 `
<div class="host-result flex items-center justify-between py-1.5 px-3 ${resultBg} rounded text-xs"
data-hostname="${this.escapeHtml(result.hostname)}">
<div class="flex items-center gap-2">
<i class="fas ${resultIcon} ${resultColor} text-[10px]"></i>
<span class="text-gray-300 font-medium">${this.escapeHtml(result.hostname)}</span>
</div>
<div class="flex items-center gap-2">
${outputPreview ? `<span class="text-gray-500 truncate max-w-[200px]" title="${this.escapeHtml(outputPreview)}">${this.escapeHtml(outputPreview.substring(0, 50))}${outputPreview.length > 50 ? '...' : ''}</span>` : ''}
<span class="px-1.5 py-0.5 rounded ${resultBg} ${resultColor} text-[10px] uppercase font-semibold">
${result.status}
</span>
</div>
</div>
`;
}).join('');
return `
<details class="task-item border-b border-gray-800 last:border-b-0" data-task-index="${taskIndex}">
<summary class="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-800/50 transition-colors">
<div class="flex items-center gap-3">
<i class="fas fa-chevron-right text-xs text-gray-600 transition-transform"></i>
<i class="fas ${taskIcon} ${taskColor}"></i>
<span class="text-sm text-gray-200">${this.escapeHtml(task.name)}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">${task.hostResults.length} hôte(s)</span>
<div class="flex -space-x-1">
${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 `<div class="w-2 h-2 rounded-full ${dotColor} border border-gray-900"></div>`;
}).join('')}
${task.hostResults.length > 5 ? `<span class="text-[10px] text-gray-500 ml-1">+${task.hostResults.length - 5}</span>` : ''}
</div>
</div>
</summary>
<div class="task-details pl-8 pr-3 pb-3 space-y-1">
${hostResultsHtml}
</div>
</details>
`;
}).join('');
return `
<div class="play-section" data-play-index="${playIndex}">
<div class="play-header flex items-center gap-3 p-3 bg-gray-800/80 border-b border-gray-700">
${playStatusIcon}
<span class="text-sm font-semibold text-white">PLAY [${this.escapeHtml(play.name)}]</span>
<span class="text-xs text-gray-500">${playTasks.length} tâche(s)</span>
</div>
<div class="play-tasks">
${tasksHtml}
</div>
</div>
`;
}).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 `
<div class="execution-stats grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<div class="stat-card p-3 bg-green-900/20 border border-green-700/30 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<i class="fas fa-check text-green-400"></i>
<span class="text-xs text-gray-400 uppercase">OK</span>
</div>
<div class="text-2xl font-bold text-green-400">${totalOk}</div>
</div>
<div class="stat-card p-3 bg-yellow-900/20 border border-yellow-700/30 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<i class="fas fa-exchange-alt text-yellow-400"></i>
<span class="text-xs text-gray-400 uppercase">Changed</span>
</div>
<div class="text-2xl font-bold text-yellow-400">${totalChanged}</div>
</div>
<div class="stat-card p-3 bg-red-900/20 border border-red-700/30 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<i class="fas fa-times text-red-400"></i>
<span class="text-xs text-gray-400 uppercase">Failed</span>
</div>
<div class="text-2xl font-bold text-red-400">${totalFailed}</div>
</div>
<div class="stat-card p-3 bg-purple-900/20 border border-purple-700/30 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<i class="fas fa-percentage text-purple-400"></i>
<span class="text-xs text-gray-400 uppercase">Success Rate</span>
</div>
<div class="text-2xl font-bold text-purple-400">${successRate}%</div>
</div>
</div>
`;
}
// 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 `
<div class="task-result p-3 bg-gray-800/50 rounded-lg mb-2">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<i class="fas ${statusIcon} ${statusColor}"></i>
<span class="text-sm text-white font-medium">${this.escapeHtml(result.taskName)}</span>
</div>
<span class="px-2 py-0.5 text-xs rounded ${result.status === 'ok' ? 'bg-green-900/50 text-green-400' : result.status === 'changed' ? 'bg-yellow-900/50 text-yellow-400' : 'bg-red-900/50 text-red-400'}">
${result.status.toUpperCase()}
</span>
</div>
${result.output ? `
<pre class="text-xs text-gray-400 bg-black/30 p-2 rounded overflow-auto max-h-32 font-mono">${this.escapeHtml(result.output)}</pre>
` : ''}
</div>
`;
}).join('');
const content = `
<div class="host-details-modal">
<div class="flex items-center justify-between mb-2">
<button type="button" onclick="dashboard.returnToStructuredPlaybookView()" class="inline-flex items-center gap-2 text-xs px-2 py-1 rounded bg-gray-800 text-gray-300 hover:bg-gray-700 transition-colors">
<i class="fas fa-arrow-left text-gray-400"></i>
<span>Retour au résumé</span>
</button>
<div class="text-xs text-gray-500 hidden sm:flex items-center gap-1">
<span class="text-gray-400">Résumé</span>
<span class="text-gray-600"></span>
<span class="text-gray-200 truncate max-w-[180px]">${this.escapeHtml(hostname)}</span>
</div>
</div>
<div class="flex items-center gap-3 mb-4 p-3 bg-gray-800 rounded-lg">
<div class="w-10 h-10 rounded-lg bg-purple-600/30 flex items-center justify-center">
<i class="fas fa-server text-purple-400"></i>
</div>
<div>
<h4 class="font-semibold text-white">${this.escapeHtml(hostname)}</h4>
<div class="text-xs text-gray-400">
<span class="text-green-400">${recapData.ok || 0} ok</span> •
<span class="text-yellow-400">${recapData.changed || 0} changed</span> •
<span class="text-red-400">${recapData.failed || 0} failed</span>
</div>
</div>
</div>
<div class="tasks-list max-h-96 overflow-auto">
${tasksHtml}
</div>
<button onclick="dashboard.closeModal()" class="w-full mt-4 px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors">
Fermer
</button>
</div>
`;
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.currentSourceTypeFilter = 'all';
this.currentHourStart = '';
this.currentHourEnd = '';
this.tasksDisplayedCount = this.tasksPerPage;
// Réinitialiser les inputs d'heure
const hourStartInput = document.getElementById('task-cal-hour-start');
const hourEndInput = document.getElementById('task-cal-hour-end');
if (hourStartInput) hourStartInput.value = '';
if (hourEndInput) hourEndInput.value = '';
this.loadTaskLogsWithFilters();
this.showNotification('Tous les filtres effacés', 'info');
}
filterTasksBySourceType(sourceType) {
this.currentSourceTypeFilter = sourceType;
this.tasksDisplayedCount = this.tasksPerPage;
this.loadTaskLogsWithFilters();
}
clearSourceTypeFilter() {
this.currentSourceTypeFilter = 'all';
this.tasksDisplayedCount = this.tasksPerPage;
this.loadTaskLogsWithFilters();
this.showNotification('Filtre type effacé', 'info');
}
clearHourFilter() {
this.currentHourStart = '';
this.currentHourEnd = '';
// Réinitialiser les inputs d'heure
const hourStartInput = document.getElementById('task-cal-hour-start');
const hourEndInput = document.getElementById('task-cal-hour-end');
if (hourStartInput) hourStartInput.value = '';
if (hourEndInput) hourEndInput.value = '';
this.tasksDisplayedCount = this.tasksPerPage;
this.loadTaskLogsWithFilters();
this.showNotification('Filtre horaire effacé', 'info');
}
async loadTaskLogsWithFilters() {
// Afficher un indicateur de chargement inline (pas le loader global)
const container = document.getElementById('tasks-list');
const logsSection = document.getElementById('task-logs-section');
if (logsSection) {
logsSection.innerHTML = `
<h4 class="text-sm font-semibold text-gray-400 mb-2 mt-4"><i class="fas fa-spinner fa-spin mr-2"></i>Chargement...</h4>
`;
} else if (container) {
// Garder le header mais montrer le chargement dans la liste
const existingHeader = container.querySelector('.flex.flex-col.gap-2.mb-4');
if (!existingHeader) {
container.innerHTML = `
<div class="flex items-center justify-center py-8 text-gray-400">
<i class="fas fa-spinner fa-spin mr-2"></i>
<span>Chargement...</span>
</div>
`;
}
}
const params = new URLSearchParams();
if (this.currentStatusFilter && this.currentStatusFilter !== 'all') {
params.append('status', this.currentStatusFilter);
}
// Si plusieurs dates sont sélectionnées, utiliser le premier jour comme filtre principal (compat API)
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
const firstDate = this.parseDateKey(this.selectedTaskDates[0]);
const year = String(firstDate.getFullYear());
const month = String(firstDate.getMonth() + 1).padStart(2, '0');
const day = String(firstDate.getDate()).padStart(2, '0');
params.append('year', year);
params.append('month', month);
params.append('day', day);
} else {
if (this.currentDateFilter.year) params.append('year', this.currentDateFilter.year);
if (this.currentDateFilter.month) params.append('month', this.currentDateFilter.month);
if (this.currentDateFilter.day) params.append('day', this.currentDateFilter.day);
}
// Filtres d'heure
if (this.currentHourStart) {
params.append('hour_start', this.currentHourStart);
}
if (this.currentHourEnd) {
params.append('hour_end', this.currentHourEnd);
}
if (this.currentTargetFilter && this.currentTargetFilter !== 'all') {
params.append('target', this.currentTargetFilter);
}
if (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') {
params.append('category', this.currentCategoryFilter);
}
// Filtre par type de source
if (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') {
params.append('source_type', this.currentSourceTypeFilter);
}
// Pagination côté serveur
params.append('limit', this.tasksPerPage);
params.append('offset', 0); // Toujours commencer à 0 lors d'un nouveau filtre
try {
const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`);
this.taskLogs = result.logs || [];
this.tasksTotalCount = result.total_count || 0;
this.tasksHasMore = result.has_more || false;
this.tasksDisplayedCount = this.taskLogs.length;
this.renderTasks();
this.updateTaskCounts();
} catch (error) {
console.error('Erreur chargement logs:', error);
}
}
updateTaskCounts() {
// Mettre à jour les compteurs dans les boutons
const stats = this.taskLogsStats || { total: 0, completed: 0, failed: 0, running: 0, pending: 0 };
const running = this.tasks.filter(t => t.status === 'running').length;
const countAll = document.getElementById('count-all');
const countRunning = document.getElementById('count-running');
const countCompleted = document.getElementById('count-completed');
const countFailed = document.getElementById('count-failed');
if (countAll) countAll.textContent = (stats.total || 0) + running;
if (countRunning) countRunning.textContent = running + (stats.running || 0);
if (countCompleted) countCompleted.textContent = stats.completed || 0;
if (countFailed) countFailed.textContent = stats.failed || 0;
// Mettre à jour le badge principal
const badge = document.getElementById('tasks-count-badge');
if (badge) badge.textContent = (stats.total || 0) + running;
console.log('Task counts updated:', { stats, running });
}
updateDateFilters() {
// Mettre à jour le libellé du bouton et le résumé sous le calendrier
const label = document.getElementById('task-date-filter-label');
const summary = document.getElementById('task-cal-summary');
let text = 'Toutes les dates';
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
if (this.selectedTaskDates.length === 1) {
const d = this.parseDateKey(this.selectedTaskDates[0]);
text = d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: '2-digit' });
} else {
text = `${this.selectedTaskDates.length} jours sélectionnés`;
}
}
if (label) label.textContent = text;
if (summary) summary.textContent = text;
}
applyDateFilter() {
// Lorsque plusieurs dates sont sélectionnées, on garde uniquement la première pour l'API
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
const firstDate = this.parseDateKey(this.selectedTaskDates[0]);
this.currentDateFilter.year = String(firstDate.getFullYear());
this.currentDateFilter.month = String(firstDate.getMonth() + 1).padStart(2, '0');
this.currentDateFilter.day = String(firstDate.getDate()).padStart(2, '0');
} else {
this.currentDateFilter = { year: '', month: '', day: '' };
}
// Récupérer les heures depuis les inputs
const hourStartInput = document.getElementById('task-cal-hour-start');
const hourEndInput = document.getElementById('task-cal-hour-end');
this.currentHourStart = hourStartInput ? hourStartInput.value : '';
this.currentHourEnd = hourEndInput ? hourEndInput.value : '';
this.updateDateFilters();
this.loadTaskLogsWithFilters();
}
clearDateFilters() {
this.currentDateFilter = { year: '', month: '', day: '' };
this.selectedTaskDates = [];
this.currentHourStart = '';
this.currentHourEnd = '';
// Réinitialiser les inputs d'heure
const hourStartInput = document.getElementById('task-cal-hour-start');
const hourEndInput = document.getElementById('task-cal-hour-end');
if (hourStartInput) hourStartInput.value = '';
if (hourEndInput) hourEndInput.value = '';
this.updateDateFilters();
this.renderTaskCalendar();
this.loadTaskLogsWithFilters();
}
async refreshTaskLogs() {
// Ne pas utiliser showLoading() pour éviter le message "Exécution de la tâche..."
// Afficher un indicateur de chargement inline à la place
const container = document.getElementById('tasks-list');
if (container) {
container.innerHTML = `
<div class="flex items-center justify-center py-8 text-gray-400">
<i class="fas fa-spinner fa-spin mr-2"></i>
<span>Chargement des logs...</span>
</div>
`;
}
try {
const [taskLogsData, taskStatsData, taskDatesData] = await Promise.all([
this.apiCall(`/api/tasks/logs?limit=${this.tasksPerPage}&offset=0`),
this.apiCall('/api/tasks/logs/stats'),
this.apiCall('/api/tasks/logs/dates')
]);
this.taskLogs = taskLogsData.logs || [];
this.tasksTotalCount = taskLogsData.total_count || 0;
this.tasksHasMore = taskLogsData.has_more || false;
this.taskLogsStats = taskStatsData;
this.taskLogsDates = taskDatesData;
this.renderTasks();
this.updateDateFilters();
this.updateTaskCounts();
this.showNotification('Logs de tâches rafraîchis', 'success');
} catch (error) {
this.showNotification(`Erreur: ${error.message}`, 'error');
if (container) {
container.innerHTML = `
<div class="text-center text-red-400 py-8">
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
<p>Erreur de chargement</p>
</div>
`;
}
}
}
createTaskCard(task, isRunning) {
const statusBadge = this.getStatusBadge(task.status);
const progressBar = isRunning ? `
<div class="w-full bg-gray-700 rounded-full h-1.5 mt-2">
<div class="bg-blue-500 h-1.5 rounded-full transition-all duration-300 animate-pulse" style="width: ${task.progress || 50}%"></div>
</div>
` : '';
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': '<i class="fas fa-check-circle text-green-500"></i>',
'failed': '<i class="fas fa-times-circle text-red-500"></i>',
'running': '<i class="fas fa-cog fa-spin text-blue-500"></i>',
'pending': '<i class="fas fa-clock text-yellow-500"></i>'
}[task.status] || '<i class="fas fa-question-circle text-gray-500"></i>';
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 = `
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-3 mb-1">
${statusIcon}
<h4 class="font-semibold text-white text-sm">${task.name}</h4>
${statusBadge}
</div>
<p class="text-xs text-gray-400 ml-6">Cible: ${task.host}</p>
<p class="text-xs text-gray-500 ml-6">Début: ${startTime} • Durée: ${duration}</p>
${progressBar}
${task.output ? `
<div class="mt-2 ml-6">
<pre class="text-xs text-gray-500 bg-gray-800 p-2 rounded max-h-16 overflow-hidden cursor-pointer hover:bg-gray-750" onclick="dashboard.viewTaskDetails(${task.id})">${this.escapeHtml(task.output.substring(0, 150))}${task.output.length > 150 ? '...' : ''}</pre>
</div>
` : ''}
${task.error ? `
<div class="mt-2 ml-6">
<pre class="text-xs text-red-400 bg-red-900/20 p-2 rounded max-h-16 overflow-hidden">${this.escapeHtml(task.error.substring(0, 150))}${task.error.length > 150 ? '...' : ''}</pre>
</div>
` : ''}
</div>
<div class="flex flex-col space-y-1 ml-2">
<button class="p-1.5 bg-gray-700 rounded hover:bg-gray-600 transition-colors" onclick="dashboard.viewTaskDetails(${task.id})" title="Voir les détails">
<i class="fas fa-eye text-gray-300 text-xs"></i>
</button>
${task.status === 'failed' ? `
<button class="p-1.5 bg-orange-700 rounded hover:bg-orange-600 transition-colors" onclick="dashboard.retryTask(${task.id})" title="Réessayer">
<i class="fas fa-redo text-white text-xs"></i>
</button>
` : ''}
</div>
</div>
`;
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}`, `
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-semibold text-lg">${task.name}</h3>
${statusBadge}
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="p-3 bg-gray-800 rounded">
<span class="text-gray-400">Cible:</span>
<span class="ml-2 text-white">${task.host}</span>
</div>
<div class="p-3 bg-gray-800 rounded">
<span class="text-gray-400">Durée:</span>
<span class="ml-2 text-white">${task.duration || '--'}</span>
</div>
<div class="p-3 bg-gray-800 rounded">
<span class="text-gray-400">Début:</span>
<span class="ml-2 text-white">${startTime}</span>
</div>
<div class="p-3 bg-gray-800 rounded">
<span class="text-gray-400">Progression:</span>
<span class="ml-2 text-white">${task.progress}%</span>
</div>
</div>
${task.output ? `
<div>
<div class="flex items-center justify-between mb-2">
<h4 class="font-semibold text-sm text-gray-300">
<i class="fas fa-terminal mr-2"></i>Sortie
</h4>
<button onclick="dashboard.copyToClipboard(\`${this.escapeHtml(task.output).replace(/`/g, '\\`')}\`)" class="text-xs text-gray-400 hover:text-white">
<i class="fas fa-copy mr-1"></i>Copier
</button>
</div>
<pre class="text-xs text-gray-300 bg-gray-900 p-4 rounded max-h-64 overflow-auto whitespace-pre-wrap font-mono">${this.escapeHtml(task.output)}</pre>
</div>
` : ''}
${task.error ? `
<div>
<h4 class="font-semibold text-sm text-red-400 mb-2">
<i class="fas fa-exclamation-triangle mr-2"></i>Erreur
</h4>
<pre class="text-xs text-red-300 bg-red-900/30 p-4 rounded max-h-40 overflow-auto whitespace-pre-wrap font-mono">${this.escapeHtml(task.error)}</pre>
</div>
` : ''}
<div class="flex space-x-3 pt-4">
${task.status === 'failed' ? `
<button onclick="dashboard.retryTask(${task.id}); dashboard.closeModal();" class="flex-1 px-4 py-2 bg-orange-600 rounded-lg hover:bg-orange-500 transition-colors">
<i class="fas fa-redo mr-2"></i>Réessayer
</button>
` : ''}
<button onclick="dashboard.closeModal()" class="flex-1 px-4 py-2 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Fermer
</button>
</div>
</div>
`);
}
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 =>
`<option value="${h.name}">${h.name} (${h.ip})</option>`
).join('');
const groupOptions = this.ansibleGroups.map(g =>
`<option value="${g}">${g}</option>`
).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 =>
`<option value="${c.name}">${c.name}</option>`
).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 += `
<div class="mb-4 history-category-section" data-category="${category}">
<h5 class="text-xs font-semibold mb-2 flex items-center" style="color: ${catInfo.color}">
<i class="fas ${catInfo.icon} mr-2"></i>${category.toUpperCase()}
<span class="ml-2 text-gray-500">(${commands.length})</span>
</h5>
<div class="space-y-1">
${commands.map(cmd => `
<div class="flex items-center justify-between p-2 bg-gray-700/50 rounded hover:bg-gray-700 cursor-pointer group"
onclick="dashboard.loadHistoryCommand(\`${cmd.command.replace(/`/g, '\\`').replace(/\\/g, '\\\\')}\`, '${cmd.target}', '${cmd.module}', ${cmd.become})">
<div class="flex-1 min-w-0">
<code class="text-xs text-green-400 block truncate">${this.escapeHtml(cmd.command)}</code>
<span class="text-xs text-gray-500">${cmd.target}</span>
</div>
<div class="flex items-center space-x-1 ml-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span class="text-xs text-gray-500 bg-gray-600 px-1 rounded">${cmd.use_count || 1}x</span>
<button onclick="event.stopPropagation(); dashboard.editHistoryCommand('${cmd.id}')"
class="p-1 hover:bg-gray-600 rounded" title="Modifier catégorie">
<i class="fas fa-tag text-xs text-gray-400"></i>
</button>
<button onclick="event.stopPropagation(); dashboard.deleteHistoryCommand('${cmd.id}')"
class="p-1 hover:bg-red-600 rounded" title="Supprimer">
<i class="fas fa-trash text-xs text-red-400"></i>
</button>
</div>
</div>
`).join('')}
</div>
</div>
`;
});
}
// Afficher les catégories disponibles avec filtrage et actions
// Ajouter "toutes" comme option de filtrage
let categoriesListHtml = `
<button type="button" onclick="dashboard.filterHistoryByCategory('all')"
data-category-filter="all"
class="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">
<i class="fas fa-list mr-1"></i>Toutes
</button>
`;
categories.forEach(cat => {
const isDefault = cat.name === 'default';
categoriesListHtml += `
<div class="group relative inline-flex">
<button type="button" onclick="dashboard.filterHistoryByCategory('${cat.name}')"
data-category-filter="${cat.name}"
class="category-filter-btn inline-flex items-center px-2 py-1 rounded-l text-xs transition-all hover:brightness-125"
style="background-color: ${cat.color}30; color: ${cat.color}">
<i class="fas ${cat.icon} mr-1"></i>${cat.name}
</button>
<div class="inline-flex">
<button type="button" onclick="event.stopPropagation(); dashboard.editCategory('${cat.name}')"
class="px-1 py-1 text-xs opacity-60 hover:opacity-100 transition-opacity"
style="background-color: ${cat.color}20; color: ${cat.color}" title="Modifier">
<i class="fas fa-pen text-[9px]"></i>
</button>
${!isDefault ? `
<button type="button" onclick="event.stopPropagation(); dashboard.deleteCategory('${cat.name}')"
class="px-1 py-1 rounded-r text-xs opacity-60 hover:opacity-100 hover:bg-red-600 hover:text-white transition-all"
style="background-color: ${cat.color}20; color: ${cat.color}" title="Supprimer">
<i class="fas fa-times text-[9px]"></i>
</button>
` : `<span class="px-1 py-1 rounded-r text-xs" style="background-color: ${cat.color}20;"></span>`}
</div>
</div>
`;
});
this.showModal('Console Ad-Hoc Ansible', `
<div class="flex flex-col lg:flex-row gap-6 adhoc-console-content">
<!-- Formulaire principal -->
<div class="flex-1 lg:flex-[3] min-w-0">
<form onsubmit="dashboard.executeAdHocCommand(event)" class="space-y-4">
<div class="p-3 bg-purple-900/30 border border-purple-600 rounded-lg text-sm">
<i class="fas fa-terminal text-purple-400 mr-2"></i>
Exécutez des commandes shell directement sur vos hôtes via Ansible
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Cible</label>
<select name="target" id="adhoc-target" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
<option value="all">Tous les hôtes</option>
<optgroup label="Groupes">
${groupOptions}
</optgroup>
<optgroup label="Hôtes">
${hostOptions}
</optgroup>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Module</label>
<select name="module" id="adhoc-module" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
<option value="shell">shell (avec pipes)</option>
<option value="command">command (simple)</option>
<option value="raw">raw (sans python)</option>
</select>
</div>
</div>
<!-- Aperçu des hôtes ciblés -->
<div id="adhoc-target-hosts-preview" class="p-3 bg-gray-800/50 border border-gray-700 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-400 uppercase tracking-wide">
<i class="fas fa-server mr-1"></i>Hôtes ciblés
</span>
<span id="adhoc-target-hosts-count" class="text-xs text-purple-400 font-medium"></span>
</div>
<div id="adhoc-target-hosts-list" class="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
<!-- Liste des hôtes sera injectée ici -->
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-code mr-1"></i>Commande
</label>
<input type="text" name="command" id="adhoc-command"
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none font-mono"
placeholder="df -h | grep '/$'" required>
</div>
<div class="flex flex-wrap items-center gap-4">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" name="become" id="adhoc-become" class="form-checkbox bg-gray-700 border-gray-600 rounded text-purple-500">
<span class="text-sm text-gray-300">Sudo (become)</span>
</label>
<div class="flex items-center space-x-2">
<label class="text-sm text-gray-300">Timeout:</label>
<input type="number" name="timeout" value="60" min="5" max="600"
class="w-20 p-2 bg-gray-700 border border-gray-600 rounded text-sm">
<span class="text-xs text-gray-500">sec</span>
</div>
<div class="flex items-center space-x-2">
<label class="text-sm text-gray-300">Catégorie:</label>
<select name="save_category" id="adhoc-category" class="p-2 bg-gray-700 border border-gray-600 rounded text-sm">
${categoryOptions}
</select>
</div>
</div>
<div id="adhoc-result" class="hidden mt-4">
<div class="border border-gray-700 rounded-xl overflow-hidden bg-gray-900/80">
<!-- Header du résultat -->
<div id="adhoc-result-header" class="flex items-center justify-between px-4 py-2 bg-gray-800/80 border-b border-gray-700">
<div class="flex items-center gap-3">
<div id="adhoc-status-icon" class="w-7 h-7 rounded-lg flex items-center justify-center bg-gray-700">
<i class="fas fa-spinner fa-spin text-gray-400"></i>
</div>
<div>
<h4 class="font-semibold text-sm text-white">Résultat d'exécution</h4>
<p id="adhoc-result-meta" class="text-xs text-gray-500">En attente...</p>
</div>
</div>
<div id="adhoc-result-stats" class="flex items-center gap-3 text-xs">
<!-- Stats seront injectées ici -->
</div>
</div>
<!-- Corps du résultat avec sections -->
<div class="p-3 space-y-3" style="max-height: 350px; overflow-y: auto;">
<!-- Section STDOUT -->
<div id="adhoc-stdout-section">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">
<i class="fas fa-terminal mr-1"></i>Sortie standard
</span>
</div>
<pre id="adhoc-stdout" class="text-sm bg-black/50 p-3 rounded-lg overflow-auto font-mono text-gray-300 leading-relaxed border border-gray-800" style="max-height: 250px;"></pre>
</div>
<!-- Section STDERR (conditionnelle) - collapsible -->
<div id="adhoc-stderr-section" class="hidden">
<details class="group">
<summary class="flex items-center gap-2 mb-1 cursor-pointer list-none">
<span class="text-xs font-medium text-amber-400 uppercase tracking-wide">
<i class="fas fa-exclamation-triangle mr-1"></i>Avertissements
<i class="fas fa-chevron-down ml-1 text-[10px] transition-transform group-open:rotate-180"></i>
</span>
</summary>
<pre id="adhoc-stderr" class="text-xs bg-amber-950/30 p-3 rounded-lg overflow-auto font-mono text-amber-200 leading-relaxed border border-amber-900/50" style="max-height: 100px;"></pre>
</details>
</div>
</div>
</div>
</div>
<div class="flex space-x-3 pt-2">
<button type="submit" class="flex-1 btn-primary bg-purple-600 hover:bg-purple-500 py-3 rounded-lg font-medium">
<i class="fas fa-play mr-2"></i>Exécuter
</button>
<button type="button" onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Fermer
</button>
</div>
</form>
</div>
<!-- Historique des commandes -->
<div class="w-full lg:w-72 xl:w-80 flex-shrink-0">
<div class="p-4 bg-gray-800/50 rounded-lg sticky top-0">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-300">
<i class="fas fa-history mr-2"></i>Historique
</h4>
<button onclick="dashboard.showAddCategoryModal()" class="text-xs text-purple-400 hover:text-purple-300">
<i class="fas fa-plus mr-1"></i>Catégorie
</button>
</div>
<!-- Catégories disponibles -->
<div class="mb-3 pb-3 border-b border-gray-700">
<p class="text-xs text-gray-500 mb-2">Catégories:</p>
<div class="flex flex-wrap gap-1">
${categoriesListHtml}
</div>
</div>
<!-- Liste des commandes - hauteur augmentée -->
<div id="adhoc-history-container" class="overflow-y-auto" style="max-height: 400px;">
${historyHtml || '<p class="text-xs text-gray-500 text-center py-4"><i class="fas fa-inbox mr-2"></i>Aucune commande dans l\'historique<br><span class="text-gray-600">Exécutez une commande pour la sauvegarder</span></p>'}
</div>
</div>
</div>
</div>
`);
// 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 = '<span class="text-xs text-gray-500 italic">Aucun hôte trouvé</span>';
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 `
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border ${statusColor}" title="${h.ip || h.name}${h.groups ? ' • Groupes: ' + h.groups.join(', ') : ''}">
<i class="fas ${statusIcon} text-[8px]"></i>
<span class="font-medium">${this.escapeHtml(h.name)}</span>
${h.ip ? `<span class="text-gray-500 text-[10px]">${h.ip}</span>` : ''}
</span>
`;
}).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 += `
<div class="mb-4 history-category-section" data-category="${category}">
<h5 class="text-xs font-semibold mb-2 flex items-center" style="color: ${catInfo.color}">
<i class="fas ${catInfo.icon} mr-2"></i>${category.toUpperCase()}
<span class="ml-2 text-gray-500">(${commands.length})</span>
</h5>
<div class="space-y-1">
${commands.map(cmd => `
<div class="flex items-center justify-between p-2 bg-gray-700/50 rounded hover:bg-gray-700 cursor-pointer group"
onclick="dashboard.loadHistoryCommand(\`${cmd.command.replace(/`/g, '\\`').replace(/\\/g, '\\\\')}\`, '${cmd.target}', '${cmd.module}', ${cmd.become})">
<div class="flex-1 min-w-0">
<code class="text-xs text-green-400 block truncate">${this.escapeHtml(cmd.command)}</code>
<span class="text-xs text-gray-500">${cmd.target}</span>
</div>
<div class="flex items-center space-x-1 ml-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span class="text-xs text-gray-500 bg-gray-600 px-1 rounded">${cmd.use_count || 1}x</span>
<button onclick="event.stopPropagation(); dashboard.editHistoryCommand('${cmd.id}')"
class="p-1 hover:bg-gray-600 rounded" title="Modifier catégorie">
<i class="fas fa-tag text-xs text-gray-400"></i>
</button>
<button onclick="event.stopPropagation(); dashboard.deleteHistoryCommand('${cmd.id}')"
class="p-1 hover:bg-red-600 rounded" title="Supprimer">
<i class="fas fa-trash text-xs text-red-400"></i>
</button>
</div>
</div>
`).join('')}
</div>
</div>
`;
});
}
// Mettre à jour le contenu avec animation
historyContainer.style.opacity = '0.5';
historyContainer.innerHTML = historyHtml || '<p class="text-xs text-gray-500 text-center py-4"><i class="fas fa-inbox mr-2"></i>Aucune commande dans l\'historique<br><span class="text-gray-600">Exécutez une commande pour la sauvegarder</span></p>';
// 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 =>
`<option value="${c.name}">${c.name}</option>`
).join('');
this.showModal('Modifier la catégorie', `
<form onsubmit="dashboard.updateCommandCategory(event, '${commandId}')" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Catégorie</label>
<select name="category" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
${categoryOptions}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Description (optionnel)</label>
<input type="text" name="description" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg"
placeholder="Description de la commande">
</div>
<div class="flex space-x-3">
<button type="submit" class="flex-1 btn-primary">Enregistrer</button>
<button type="button" onclick="dashboard.showAdHocConsole()" class="px-4 py-2 border border-gray-600 rounded-lg">Annuler</button>
</div>
</form>
`);
}
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', `
<form onsubmit="dashboard.createCategory(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Nom</label>
<input type="text" name="name" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg"
placeholder="monitoring" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Description</label>
<input type="text" name="description" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg"
placeholder="Commandes de monitoring">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Couleur</label>
<input type="color" name="color" value="#7c3aed" class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Icône</label>
<select name="icon" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
<option value="fa-folder">Dossier</option>
<option value="fa-terminal">Terminal</option>
<option value="fa-server">Serveur</option>
<option value="fa-database">Base de données</option>
<option value="fa-network-wired">Réseau</option>
<option value="fa-shield-alt">Sécurité</option>
<option value="fa-chart-line">Monitoring</option>
</select>
</div>
</div>
<div class="flex space-x-3">
<button type="submit" class="flex-1 btn-primary">Créer</button>
<button type="button" onclick="dashboard.showAdHocConsole()" class="px-4 py-2 border border-gray-600 rounded-lg">Annuler</button>
</div>
</form>
`);
}
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 = '<i class="fas fa-filter mr-2"></i>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}`, `
<form onsubmit="dashboard.updateCategory(event, '${categoryName}')" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Nom</label>
<input type="text" name="name" value="${this.escapeHtml(category.name)}"
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg"
${categoryName === 'default' ? 'disabled' : ''} required>
${categoryName === 'default' ? '<p class="text-xs text-gray-500 mt-1">La catégorie par défaut ne peut pas être renommée</p>' : ''}
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Description</label>
<input type="text" name="description" value="${this.escapeHtml(category.description || '')}"
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg"
placeholder="Description de la catégorie">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Couleur</label>
<input type="color" name="color" value="${category.color || '#7c3aed'}"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Icône</label>
<select name="icon" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
<option value="fa-folder" ${category.icon === 'fa-folder' ? 'selected' : ''}>📁 Dossier</option>
<option value="fa-terminal" ${category.icon === 'fa-terminal' ? 'selected' : ''}>💻 Terminal</option>
<option value="fa-server" ${category.icon === 'fa-server' ? 'selected' : ''}>🖥️ Serveur</option>
<option value="fa-database" ${category.icon === 'fa-database' ? 'selected' : ''}>🗄️ Base de données</option>
<option value="fa-network-wired" ${category.icon === 'fa-network-wired' ? 'selected' : ''}>🌐 Réseau</option>
<option value="fa-shield-alt" ${category.icon === 'fa-shield-alt' ? 'selected' : ''}>🛡️ Sécurité</option>
<option value="fa-chart-line" ${category.icon === 'fa-chart-line' ? 'selected' : ''}>📈 Monitoring</option>
<option value="fa-wrench" ${category.icon === 'fa-wrench' ? 'selected' : ''}>🔧 Maintenance</option>
<option value="fa-rocket" ${category.icon === 'fa-rocket' ? 'selected' : ''}>🚀 Déploiement</option>
<option value="fa-stethoscope" ${category.icon === 'fa-stethoscope' ? 'selected' : ''}>🩺 Diagnostic</option>
<option value="fa-cog" ${category.icon === 'fa-cog' ? 'selected' : ''}>⚙️ Configuration</option>
</select>
</div>
</div>
<!-- Aperçu -->
<div class="p-3 bg-gray-800 rounded-lg">
<p class="text-xs text-gray-500 mb-2">Aperçu:</p>
<span class="inline-flex items-center px-3 py-1.5 rounded text-sm"
style="background-color: ${category.color}30; color: ${category.color}">
<i class="fas ${category.icon} mr-2"></i>${category.name}
</span>
</div>
<div class="flex space-x-3">
<button type="submit" class="flex-1 btn-primary bg-purple-600 hover:bg-purple-500">
<i class="fas fa-save mr-2"></i>Enregistrer
</button>
<button type="button" onclick="dashboard.showAdHocConsole()"
class="px-4 py-2 border border-gray-600 rounded-lg hover:border-gray-500">
Annuler
</button>
</div>
</form>
`);
}
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 = '<i class="fas fa-spinner fa-spin text-blue-400"></i>';
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 = '<span class="text-blue-400 animate-pulse">⏳ Exécution de la commande...</span>';
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 = '<i class="fas fa-check text-green-400"></i>';
statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-green-900/50';
resultMeta.innerHTML = `<span class="text-green-400 font-medium">Succès</span> • 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 = '<i class="fas fa-times text-red-400"></i>';
statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-red-900/50';
resultMeta.innerHTML = `<span class="text-red-400 font-medium">Échec</span> • Cible: ${this.escapeHtml(result.target)}`;
}
// Stats dans le header
resultStats.innerHTML = `
<div class="flex items-center gap-1 px-2 py-1 bg-gray-700/50 rounded">
<i class="fas fa-clock text-gray-400"></i>
<span class="text-gray-300">${result.duration}s</span>
</div>
<div class="flex items-center gap-1 px-2 py-1 bg-gray-700/50 rounded">
<i class="fas fa-hashtag text-gray-400"></i>
<span class="text-gray-300">Code: ${result.return_code}</span>
</div>
`;
// 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 = '<i class="fas fa-exclamation-circle text-red-400"></i>';
statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-red-900/50';
resultMeta.innerHTML = '<span class="text-red-400 font-medium">Erreur de connexion</span>';
resultStats.innerHTML = '';
stdoutPre.innerHTML = `<span class="text-red-400">❌ ${this.escapeHtml(error.message)}</span>`;
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*=&gt;/gm,
'<span class="text-green-400 font-semibold">$1</span> <span class="px-1.5 py-0.5 bg-green-900/50 text-green-300 text-xs rounded">$2</span> =>');
formatted = formatted.replace(/^(\S+)\s*\|\s*(FAILED|UNREACHABLE)\s*(!)?\s*=&gt;/gm,
'<span class="text-red-400 font-semibold">$1</span> <span class="px-1.5 py-0.5 bg-red-900/50 text-red-300 text-xs rounded">$2</span> =>');
// Colorer les clés JSON
formatted = formatted.replace(/"(\w+)"\s*:/g, '<span class="text-purple-400">"$1"</span>:');
// Colorer les valeurs importantes
formatted = formatted.replace(/: "([^"]+)"/g, ': <span class="text-cyan-300">"$1"</span>');
formatted = formatted.replace(/: (true|false)/g, ': <span class="text-yellow-400">$1</span>');
formatted = formatted.replace(/: (\d+)/g, ': <span class="text-orange-400">$1</span>');
// Mettre en évidence les lignes de résumé
formatted = formatted.replace(/^(PLAY RECAP \*+)$/gm, '<span class="text-purple-400 font-bold">$1</span>');
formatted = formatted.replace(/(ok=\d+)/g, '<span class="text-green-400">$1</span>');
formatted = formatted.replace(/(changed=\d+)/g, '<span class="text-yellow-400">$1</span>');
formatted = formatted.replace(/(unreachable=\d+)/g, '<span class="text-orange-400">$1</span>');
formatted = formatted.replace(/(failed=\d+)/g, '<span class="text-red-400">$1</span>');
return formatted;
}
formatAnsibleWarnings(stderr) {
let formatted = this.escapeHtml(stderr);
// Mettre en évidence les warnings
formatted = formatted.replace(/\[WARNING\]:/g, '<span class="text-amber-400 font-semibold">[WARNING]:</span>');
formatted = formatted.replace(/\[DEPRECATION WARNING\]:/g, '<span class="text-orange-400 font-semibold">[DEPRECATION WARNING]:</span>');
// Colorer les URLs
formatted = formatted.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" class="text-blue-400 hover:underline">$1</a>');
// Mettre en évidence les chemins de fichiers
formatted = formatted.replace(/(\/[\w\-\.\/]+)/g, '<span class="text-gray-400">$1</span>');
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 `
<button type="button"
onclick="dashboard.switchHostTab(${index})"
data-host-tab="${index}"
class="host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all ${index === 0 ? statusColor + ' text-white' : 'bg-gray-700/50 text-gray-400 hover:text-white'}">
<i class="fas ${statusIcon} text-[10px]"></i>
<span class="truncate max-w-[120px]">${this.escapeHtml(host.hostname)}</span>
</button>
`;
}).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 = `
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">
<i class="fas fa-terminal mr-1"></i>Sortie par hôte
</span>
<span class="text-xs text-gray-500">
(${hostOutputs.length} hôtes:
<span class="text-green-400">${successCount} OK</span>
${failedCount > 0 ? `<span class="text-red-400">, ${failedCount} échec</span>` : ''})
</span>
</div>
<button type="button" onclick="dashboard.showAllHostsOutput()"
class="text-xs text-purple-400 hover:text-purple-300 transition-colors">
<i class="fas fa-list mr-1"></i>Voir tout
</button>
</div>
<!-- Onglets des hôtes -->
<div class="flex flex-wrap gap-1 mb-0 border-b border-gray-700 pb-0">
${tabsHtml}
</div>
<!-- Contenu de l'onglet actif -->
<pre id="adhoc-stdout" class="text-sm bg-black/50 p-3 rounded-b-lg rounded-tr-lg overflow-auto font-mono text-gray-300 leading-relaxed border border-gray-800 border-t-0" style="max-height: 280px;">${this.formatAnsibleOutput(hostOutputs[0].output, isSuccess)}</pre>
`;
}
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 = `
<div class="text-center text-gray-500 py-4">
<p>Aucun log disponible</p>
</div>
`;
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 = `
<div class="flex items-start space-x-3">
<span class="text-gray-500 text-xs whitespace-nowrap">${timestamp}</span>
<span class="px-2 py-1 text-xs rounded ${levelColor}">${log.level}</span>
<span class="text-gray-300 text-sm">${log.message}</span>
${log.host ? `<span class="text-xs text-purple-400">[${log.host}]</span>` : ''}
</div>
`;
container.appendChild(logEntry);
});
// Auto-scroll to bottom
container.scrollTop = container.scrollHeight;
}
getStatusBadge(status) {
const badges = {
'completed': '<span class="px-2 py-1 bg-green-600 text-xs rounded">Terminé</span>',
'running': '<span class="px-2 py-1 bg-blue-600 text-xs rounded">En cours</span>',
'pending': '<span class="px-2 py-1 bg-yellow-600 text-xs rounded">En attente</span>',
'failed': '<span class="px-2 py-1 bg-red-600 text-xs rounded">Échoué</span>'
};
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 =>
`<option value="${g}">${g}</option>`
).join('');
this.showModal('Actions Rapides - Ansible', `
<div class="space-y-4">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-300 mb-2">Cible (groupe ou hôte)</label>
<select id="ansible-target" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
<option value="all">Tous les hôtes</option>
${groupOptions}
</select>
</div>
<button class="w-full p-4 bg-gradient-to-r from-green-600 to-green-700 rounded-lg text-left hover:from-green-500 hover:to-green-600 transition-all" onclick="dashboard.executeAnsibleTask('upgrade')">
<i class="fas fa-arrow-up mr-3"></i>
Mettre à jour les systèmes (vm-upgrade.yml)
</button>
<button class="w-full p-4 bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg text-left hover:from-blue-500 hover:to-blue-600 transition-all" onclick="dashboard.executeAnsibleTask('reboot')">
<i class="fas fa-redo mr-3"></i>
Redémarrer les hôtes (vm-reboot.yml)
</button>
<button class="w-full p-4 bg-gradient-to-r from-purple-600 to-purple-700 rounded-lg text-left hover:from-purple-500 hover:to-purple-600 transition-all" onclick="dashboard.executeAnsibleTask('health-check')">
<i class="fas fa-heartbeat mr-3"></i>
Vérifier la santé (health-check.yml)
</button>
<button class="w-full p-4 bg-gradient-to-r from-orange-600 to-orange-700 rounded-lg text-left hover:from-orange-500 hover:to-orange-600 transition-all" onclick="dashboard.executeAnsibleTask('backup')">
<i class="fas fa-save mr-3"></i>
Sauvegarder la config (backup-config.yml)
</button>
</div>
`);
}
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}`, `
<form onsubmit="dashboard.executeBootstrap(event)" class="space-y-4">
<div class="p-4 bg-yellow-900/30 border border-yellow-600 rounded-lg mb-4">
<div class="flex items-start space-x-3">
<i class="fas fa-exclamation-triangle text-yellow-500 mt-1"></i>
<div class="text-sm text-yellow-200">
<p class="font-semibold mb-1">Cette opération va :</p>
<ul class="list-disc list-inside text-xs space-y-1">
<li>Créer l'utilisateur d'automatisation</li>
<li>Configurer l'authentification SSH par clé</li>
<li>Installer et configurer sudo</li>
<li>Installer Python3 (requis par Ansible)</li>
</ul>
</div>
</div>
</div>
<input type="hidden" name="host" value="${hostIp || hostName}">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Hôte cible</label>
<input type="text" value="${hostIp || hostName}" disabled
class="w-full p-3 bg-gray-800 border border-gray-600 rounded-lg text-gray-400">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-key mr-1"></i>Mot de passe root
</label>
<input type="password" name="root_password"
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-yellow-500 focus:outline-none"
placeholder="Mot de passe root de l'hôte" required>
<p class="text-xs text-gray-500 mt-1">Utilisé uniquement pour la configuration initiale</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-user mr-1"></i>Utilisateur d'automatisation
</label>
<input type="text" name="automation_user" value="automation"
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-yellow-500 focus:outline-none"
placeholder="automation">
</div>
<div class="flex space-x-3 pt-4">
<button type="submit" class="flex-1 btn-primary bg-yellow-600 hover:bg-yellow-500">
<i class="fas fa-rocket mr-2"></i>
Lancer le Bootstrap
</button>
<button type="button" onclick="dashboard.closeModal()"
class="flex-1 px-4 py-2 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Annuler
</button>
</div>
</form>
`);
}
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', `
<div class="space-y-4">
<div class="p-4 bg-green-900/30 border border-green-600 rounded-lg">
<div class="flex items-center space-x-3">
<i class="fas fa-check-circle text-green-500 text-2xl"></i>
<div>
<p class="font-semibold text-green-200">Configuration terminée!</p>
<p class="text-sm text-green-300">L'hôte ${host} est prêt pour Ansible</p>
</div>
</div>
</div>
<div class="p-4 bg-gray-800 rounded-lg">
<h4 class="font-semibold mb-2 text-sm">Détails</h4>
<pre class="text-xs text-gray-400 max-h-60 overflow-auto whitespace-pre-wrap">${result.stdout || 'Pas de sortie'}</pre>
</div>
<button onclick="dashboard.closeModal()" class="w-full btn-primary">
Fermer
</button>
</div>
`);
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', `
<div class="space-y-4">
<div class="p-4 bg-red-900/30 border border-red-600 rounded-lg">
<div class="flex items-center space-x-3">
<i class="fas fa-times-circle text-red-500 text-2xl"></i>
<div>
<p class="font-semibold text-red-200">Bootstrap échoué</p>
<p class="text-sm text-red-300">${errorDetail}</p>
</div>
</div>
</div>
${stderr ? `
<div class="p-4 bg-gray-800 rounded-lg">
<h4 class="font-semibold mb-2 text-sm text-red-400">Erreur</h4>
<pre class="text-xs text-red-300 max-h-40 overflow-auto whitespace-pre-wrap">${stderr}</pre>
</div>
` : ''}
${stdout ? `
<div class="p-4 bg-gray-800 rounded-lg">
<h4 class="font-semibold mb-2 text-sm">Sortie</h4>
<pre class="text-xs text-gray-400 max-h-40 overflow-auto whitespace-pre-wrap">${stdout}</pre>
</div>
` : ''}
<button onclick="dashboard.closeModal()" class="w-full btn-primary bg-red-600 hover:bg-red-500">
Fermer
</button>
</div>
`);
}
}
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
? `<div class="flex items-center text-green-400">
<i class="fas fa-check-circle mr-2"></i>
<span>Ansible Ready</span>
<span class="text-xs text-gray-500 ml-2">(${bootstrapDate || 'N/A'})</span>
</div>`
: `<div class="flex items-center text-yellow-400">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span>Non configuré - Bootstrap requis</span>
</div>`;
this.showModal(`Gérer ${host.name}`, `
<div class="space-y-4">
<div class="p-4 bg-gray-800 rounded-lg">
<h4 class="font-semibold mb-3">Informations de l'hôte</h4>
<div class="grid grid-cols-2 gap-2 text-sm">
<p class="text-gray-400">Nom:</p>
<p class="text-gray-200">${host.name}</p>
<p class="text-gray-400">IP:</p>
<p class="text-gray-200">${host.ip}</p>
<p class="text-gray-400">OS:</p>
<p class="text-gray-200">${host.os}</p>
<p class="text-gray-400">Statut:</p>
<p class="text-gray-200">${host.status}</p>
<p class="text-gray-400">Dernière connexion:</p>
<p class="text-gray-200">${lastSeen}</p>
</div>
</div>
<div class="p-4 ${bootstrapOk ? 'bg-green-900/20 border border-green-600/30' : 'bg-yellow-900/20 border border-yellow-600/30'} rounded-lg">
<h4 class="font-semibold mb-2 text-sm">Statut Bootstrap Ansible</h4>
${bootstrapStatusHtml}
</div>
<div class="grid grid-cols-2 gap-3">
<button class="p-3 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors" onclick="dashboard.executeHostAction('connect', '${host.name}')">
<i class="fas fa-heartbeat mr-2"></i>Health Check
</button>
<button class="p-3 bg-green-600 rounded-lg hover:bg-green-500 transition-colors" onclick="dashboard.executeHostAction('update', '${host.name}')">
<i class="fas fa-arrow-up mr-2"></i>Upgrade
</button>
<button class="p-3 bg-orange-600 rounded-lg hover:bg-orange-500 transition-colors" onclick="dashboard.executeHostAction('reboot', '${host.name}')">
<i class="fas fa-redo mr-2"></i>Reboot
</button>
<button class="p-3 bg-blue-600 rounded-lg hover:bg-blue-500 transition-colors" onclick="dashboard.executeHostAction('backup', '${host.name}')">
<i class="fas fa-save mr-2"></i>Backup
</button>
<button class="p-3 ${bootstrapOk ? 'bg-gray-600' : 'bg-yellow-600 animate-pulse'} rounded-lg hover:bg-yellow-500 transition-colors col-span-2" onclick="dashboard.showBootstrapModal('${host.name}', '${host.ip}')">
<i class="fas fa-tools mr-2"></i>${bootstrapOk ? 'Re-Bootstrap SSH' : 'Bootstrap SSH (Requis)'}
</button>
</div>
</div>
`);
}
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`, `
<div class="space-y-4">
<div class="p-4 bg-gray-800 rounded-lg">
<h4 class="font-semibold mb-2">${task.name}</h4>
<p class="text-sm text-gray-300">Hôte: ${task.host}</p>
<p class="text-sm text-gray-300">Statut: ${this.getStatusBadge(task.status)}</p>
<p class="text-sm text-gray-300">Progression: ${task.progress}%</p>
<p class="text-sm text-gray-300">Durée: ${task.duration}</p>
</div>
<div class="p-4 bg-gray-800 rounded-lg">
<h5 class="font-medium mb-2">Logs de la tâche</h5>
<div class="text-xs text-gray-400 space-y-1 max-h-32 overflow-y-auto">
<p>• Démarrage de la tâche...</p>
<p>• Connexion SSH établie</p>
<p>• Exécution des commandes...</p>
<p>• Tâche terminée avec succès</p>
</div>
</div>
</div>
`);
}
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 = `<i class="fas fa-file-code mr-1"></i>${filteredPlaybooks.length} playbook${filteredPlaybooks.length > 1 ? 's' : ''}`;
}
if (filteredPlaybooks.length === 0) {
container.innerHTML = `
<div class="text-center py-12 text-gray-500">
<i class="fas fa-file-code text-4xl mb-4 opacity-50"></i>
<p class="mb-2">Aucun playbook trouvé</p>
${this.currentPlaybookSearch || this.currentPlaybookCategoryFilter !== 'all'
? '<p class="text-sm">Essayez de modifier vos filtres</p>'
: '<button onclick="dashboard.showCreatePlaybookModal()" class="mt-4 px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors"><i class="fas fa-plus mr-2"></i>Créer un playbook</button>'}
</div>
`;
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 = [
`<button onclick="dashboard.filterPlaybooksByCategory('all')"
class="playbook-filter-btn px-3 py-1.5 text-xs rounded-lg transition-colors ${!this.currentPlaybookCategoryFilter || this.currentPlaybookCategoryFilter === 'all' ? 'bg-purple-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}" data-category="all">
Tous
</button>`,
...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 `
<button onclick="dashboard.filterPlaybooksByCategory('${this.escapeHtml(cat)}')"
class="playbook-filter-btn px-3 py-1.5 text-xs rounded-lg transition-colors ${activeClasses}"
data-category="${this.escapeHtml(cat)}">
${icon ? `<i class="fas ${icon} mr-1"></i>` : ''}${this.escapeHtml(label)}
</button>
`;
})
].join('');
container.innerHTML = `
<span class="text-xs text-gray-500 mr-2"><i class="fas fa-filter mr-1"></i>Catégorie:</span>
${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 `
<div class="playbook-card group" data-playbook="${this.escapeHtml(playbook.filename)}">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2">
<i class="fas fa-file-code text-purple-400"></i>
<h4 class="font-medium text-white truncate">${this.escapeHtml(playbook.filename)}</h4>
<span class="playbook-category-badge ${categoryClass}">${categoryLabel}</span>
</div>
${playbook.description ? `<p class="text-sm text-gray-400 mb-2 ml-7">${this.escapeHtml(playbook.description)}</p>` : ''}
<div class="flex items-center gap-4 text-xs text-gray-500 ml-7">
<span class="time-ago"><i class="fas fa-clock mr-1"></i>${modifiedAgo}</span>
<span class="file-size-badge">${sizeKb} KB</span>
${playbook.subcategory ? `<span class="text-purple-400">${playbook.subcategory}</span>` : ''}
</div>
</div>
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button class="playbook-action-btn edit" onclick="event.stopPropagation(); dashboard.editPlaybook('${this.escapeHtml(playbook.filename)}')" title="Modifier">
<i class="fas fa-edit"></i>
</button>
<button class="playbook-action-btn run" onclick="event.stopPropagation(); dashboard.runPlaybook('${this.escapeHtml(playbook.filename)}')" title="Exécuter">
<i class="fas fa-play"></i>
</button>
<button class="playbook-action-btn" style="background: rgba(124, 58, 237, 0.2); color: #a78bfa;" onclick="event.stopPropagation(); dashboard.showCreateScheduleModal('${this.escapeHtml(playbook.filename)}')" title="Planifier">
<i class="fas fa-calendar-alt"></i>
</button>
<button class="playbook-action-btn delete" onclick="event.stopPropagation(); dashboard.confirmDeletePlaybook('${this.escapeHtml(playbook.filename)}')" title="Supprimer">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
`;
}
getCategoryLabel(category) {
const labels = {
'maintenance': 'Maintenance',
'deploy': 'Deploy',
'backup': 'Backup',
'monitoring': 'Monitoring',
'system': 'System',
'general': 'Général',
'testing': 'Testing'
};
return labels[category] || category;
}
getPlaybookCategoryIcon(category) {
const icons = {
'maintenance': 'fa-wrench',
'deploy': 'fa-rocket',
'backup': 'fa-save',
'monitoring': 'fa-heartbeat',
'system': 'fa-cogs',
'testing': 'fa-flask'
};
return icons[category] || null;
}
getRelativeTime(dateString) {
if (!dateString) return 'Date inconnue';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'À l\'instant';
if (diffMin < 60) return `il y a ${diffMin} min`;
if (diffHour < 24) return `il y a ${diffHour}h`;
if (diffDay === 1) return 'Hier';
if (diffDay < 7) return `il y a ${diffDay} jours`;
if (diffDay < 30) return `il y a ${Math.floor(diffDay / 7)} sem.`;
return date.toLocaleDateString('fr-FR');
}
filterPlaybooks(searchText) {
this.currentPlaybookSearch = searchText;
this.renderPlaybooks();
}
filterPlaybooksByCategory(category) {
this.currentPlaybookCategoryFilter = category;
// Mettre à jour les boutons de filtre
document.querySelectorAll('.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', `
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-file-code mr-1"></i>Nom du fichier <span class="text-red-400">*</span>
</label>
<div class="flex items-center">
<input type="text" id="new-playbook-name"
class="flex-1 p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none"
placeholder="mon-playbook" pattern="[a-zA-Z0-9_-]+" required>
<span class="px-3 py-3 bg-gray-600 border border-gray-500 border-l-0 rounded-r-lg text-gray-400">.yml</span>
</div>
<p class="text-xs text-gray-500 mt-1">Lettres, chiffres, tirets et underscores uniquement</p>
</div>
<div class="flex space-x-3 pt-4">
<button onclick="dashboard.createNewPlaybook()" class="flex-1 btn-primary bg-purple-600 hover:bg-purple-500 py-3 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>Créer et Éditer
</button>
<button onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Annuler
</button>
</div>
</div>
`);
}
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 = `
<div class="space-y-4">
<!-- Barre d'outils -->
<div class="editor-toolbar flex items-center justify-between rounded-t-lg -mx-8 -mt-8 mb-4 px-6 py-3">
<div class="flex items-center gap-3">
<i class="fas fa-file-code text-purple-400 text-xl"></i>
<span class="font-medium text-white">${this.escapeHtml(filename)}</span>
${isNew ? '<span class="px-2 py-0.5 bg-green-600/30 text-green-400 text-xs rounded">Nouveau</span>' : ''}
</div>
<div class="yaml-hint hidden sm:block">
<i class="fas fa-info-circle mr-1"></i>
Format YAML • Indentation: 2 espaces
</div>
</div>
<!-- Éditeur de code -->
<div class="playbook-editor-container">
<textarea id="playbook-editor-content"
class="playbook-code-editor"
spellcheck="false"
wrap="off">${this.escapeHtml(content)}</textarea>
</div>
<!-- Pied de page avec validation -->
<div class="flex items-center justify-between pt-4 border-t border-gray-700">
<div id="yaml-validation-status" class="text-sm">
<i class="fas fa-check-circle text-green-400 mr-1"></i>
<span class="text-gray-400">YAML valide</span>
</div>
<div class="flex gap-3">
<button onclick="dashboard.closeModal()" class="px-4 py-2 bg-gray-600 rounded-lg hover:bg-gray-500 transition-colors">
<i class="fas fa-times mr-2"></i>Annuler
</button>
<button onclick="dashboard.savePlaybook('${this.escapeHtml(filename)}', ${isNew})"
class="px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors shadow-lg shadow-purple-500/20">
<i class="fas fa-save mr-2"></i>Sauvegarder
</button>
</div>
</div>
</div>
`;
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 = `
<i class="fas fa-exclamation-triangle text-yellow-400 mr-1"></i>
<span class="text-yellow-400">${errors[0]}</span>
`;
} else {
statusEl.innerHTML = `
<i class="fas fa-check-circle text-green-400 mr-1"></i>
<span class="text-gray-400">YAML valide</span>
`;
}
}
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 = [
'<option value="all">Tous les hôtes (all)</option>',
...this.ansibleGroups.map(g => `<option value="${g}">${g}</option>`)
].join('');
this.showModal(`Exécuter: ${this.escapeHtml(filename)}`, `
<div class="space-y-4">
<div class="p-3 bg-purple-900/30 border border-purple-600 rounded-lg text-sm">
<i class="fas fa-play-circle text-purple-400 mr-2"></i>
Configurez les options d'exécution du playbook
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-bullseye mr-1"></i>Cible (hosts/groupe)
</label>
<select id="run-playbook-target" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none">
${targetOptions}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<i class="fas fa-cog mr-1"></i>Variables supplémentaires (JSON, optionnel)
</label>
<textarea id="run-playbook-vars" rows="3"
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none font-mono text-sm"
placeholder='{"key": "value"}'></textarea>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="run-playbook-check" class="rounded bg-gray-700 border-gray-600 text-purple-500 focus:ring-purple-500">
<span class="text-sm text-gray-300">Mode simulation (--check)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="run-playbook-verbose" class="rounded bg-gray-700 border-gray-600 text-purple-500 focus:ring-purple-500">
<span class="text-sm text-gray-300">Verbose (-v)</span>
</label>
</div>
<div class="flex space-x-3 pt-4 border-t border-gray-700">
<button onclick="dashboard.executePlaybookFromModal('${this.escapeHtml(filename)}')" class="flex-1 btn-primary bg-green-600 hover:bg-green-500 py-3 rounded-lg font-medium">
<i class="fas fa-play mr-2"></i>Exécuter
</button>
<button onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Annuler
</button>
</div>
</div>
`);
}
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}`, `
<div class="space-y-4">
<div class="p-4 ${statusColor} border rounded-lg">
<div class="flex items-center space-x-3">
<i class="fas ${statusIcon} text-2xl"></i>
<div>
<p class="font-semibold">${result.success ? 'Exécution réussie' : 'Échec de l\'exécution'}</p>
<p class="text-sm text-gray-400">Cible: ${target} • Durée: ${result.execution_time || '?'}s</p>
</div>
</div>
</div>
<div class="p-4 bg-gray-800 rounded-lg max-h-64 overflow-auto">
<pre class="text-xs text-gray-300 whitespace-pre-wrap font-mono">${this.escapeHtml(result.stdout || '(pas de sortie)')}</pre>
</div>
${result.stderr ? `
<div class="p-4 bg-red-900/20 rounded-lg max-h-32 overflow-auto">
<h4 class="text-sm font-semibold text-red-400 mb-2">Erreurs:</h4>
<pre class="text-xs text-red-300 whitespace-pre-wrap font-mono">${this.escapeHtml(result.stderr)}</pre>
</div>
` : ''}
<button onclick="dashboard.closeModal()" class="w-full btn-primary">Fermer</button>
</div>
`);
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', `
<div class="space-y-4">
<div class="p-4 bg-red-900/30 border border-red-600 rounded-lg">
<div class="flex items-start space-x-3">
<i class="fas fa-exclamation-triangle text-red-500 text-2xl mt-1"></i>
<div>
<h4 class="font-semibold text-red-400">Attention !</h4>
<p class="text-sm text-gray-300 mt-1">
Vous êtes sur le point de supprimer le playbook <strong class="text-white">${this.escapeHtml(filename)}</strong>.
</p>
<p class="text-sm text-gray-400 mt-2">
Cette action est irréversible.
</p>
</div>
</div>
</div>
<div class="flex space-x-3">
<button onclick="dashboard.deletePlaybook('${this.escapeHtml(filename)}')" class="flex-1 px-4 py-3 bg-red-600 rounded-lg hover:bg-red-500 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>Supprimer définitivement
</button>
<button onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
Annuler
</button>
</div>
</div>
`);
}
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 = `
<div class="text-center py-8 text-gray-400">
<i class="fas fa-search text-2xl mb-2"></i>
<p>Aucun schedule trouvé pour ces critères</p>
</div>
`;
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 = `<span class="text-gray-500">| Dernier: ${lastStatusIcon} ${this.formatRelativeTime(lastRunDate)}</span>`;
}
// 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 =>
`<span class="schedule-tag">${tag}</span>`
).join('');
return `
<div class="schedule-card ${statusClass}" data-schedule-id="${schedule.id}">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2">
<span class="font-semibold text-lg">${schedule.name}</span>
<span class="schedule-status-chip ${statusChipClass}">${statusText}</span>
${tagsHtml}
</div>
<div class="text-sm text-gray-400 mb-2">
${schedule.description || ''}
</div>
<div class="flex flex-wrap items-center gap-3 text-xs">
<span class="px-2 py-1 bg-blue-600/20 text-blue-400 rounded">${schedule.playbook}</span>
<span class="px-2 py-1 bg-purple-600/20 text-purple-400 rounded">
<i class="fas fa-${schedule.target_type === 'group' ? 'layer-group' : 'server'} mr-1"></i>
${schedule.target}
</span>
<span class="text-gray-500">${recurrenceText}</span>
</div>
<div class="mt-2 text-xs text-gray-400">
<span>Prochaine: <span class="text-blue-400">${nextRunText}</span></span>
${lastRunHtml}
</div>
</div>
<div class="flex items-center gap-2 ml-4">
<button onclick="dashboard.runScheduleNow('${schedule.id}')" class="schedule-action-btn run" title="Exécuter maintenant">
<i class="fas fa-play"></i>
</button>
${schedule.enabled ? `
<button onclick="dashboard.pauseSchedule('${schedule.id}')" class="schedule-action-btn pause" title="Mettre en pause">
<i class="fas fa-pause"></i>
</button>
` : `
<button onclick="dashboard.resumeSchedule('${schedule.id}')" class="schedule-action-btn run" title="Reprendre">
<i class="fas fa-play"></i>
</button>
`}
<button onclick="dashboard.showEditScheduleModal('${schedule.id}')" class="schedule-action-btn edit" title="Modifier">
<i class="fas fa-edit"></i>
</button>
<button onclick="dashboard.showScheduleHistory('${schedule.id}')" class="schedule-action-btn edit" title="Historique">
<i class="fas fa-history"></i>
</button>
<button onclick="dashboard.deleteSchedule('${schedule.id}')" class="schedule-action-btn delete" title="Supprimer">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
`;
}
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 = '<p class="text-gray-500 text-center py-2">Aucun schedule actif</p>';
return;
}
container.innerHTML = upcoming.map(s => {
const nextRun = new Date(s.next_run_at);
return `
<div class="flex items-center justify-between p-2 bg-gray-800/30 rounded">
<div class="flex items-center gap-2 min-w-0">
<div class="w-1.5 h-1.5 bg-green-400 rounded-full flex-shrink-0"></div>
<span class="truncate">${s.name}</span>
</div>
<span class="text-blue-400 text-xs flex-shrink-0 ml-2">${nextRun.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
`;
}).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 = '<p class="text-gray-500 text-sm">Aucune exécution planifiée</p>';
return;
}
container.innerHTML = activeSchedules.map(s => {
const nextRun = new Date(s.next_run_at);
return `
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 bg-green-400 rounded-full"></div>
<div>
<div class="font-medium text-sm">${s.name}</div>
<div class="text-xs text-gray-500">${s.playbook}${s.target}</div>
</div>
</div>
<div class="text-right">
<div class="text-sm text-blue-400">${nextRun.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</div>
<div class="text-xs text-gray-500">${nextRun.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })}</div>
</div>
</div>
`;
}).join('');
}
formatRelativeTime(date) {
const now = new Date();
const diffMs = date - now;
const absDiffMs = Math.abs(diffMs);
const isPast = diffMs < 0;
const mins = Math.floor(absDiffMs / (1000 * 60));
const hours = Math.floor(absDiffMs / (1000 * 60 * 60));
const days = Math.floor(absDiffMs / (1000 * 60 * 60 * 24));
if (days > 0) {
return isPast ? `il y a ${days}j` : `dans ${days}j`;
} else if (hours > 0) {
return isPast ? `il y a ${hours}h` : `dans ${hours}h`;
} else if (mins > 0) {
return isPast ? `il y a ${mins}min` : `dans ${mins}min`;
} else {
return isPast ? 'à l\'instant' : 'imminent';
}
}
filterSchedules(filter) {
this.currentScheduleFilter = filter;
// Mettre à jour les boutons
document.querySelectorAll('.schedule-filter-btn').forEach(btn => {
btn.classList.remove('active', 'bg-purple-600', 'text-white');
btn.classList.add('bg-gray-700', 'text-gray-300');
if (btn.dataset.filter === filter) {
btn.classList.add('active', 'bg-purple-600', 'text-white');
btn.classList.remove('bg-gray-700', 'text-gray-300');
}
});
this.renderSchedules();
}
searchSchedules(query) {
this.scheduleSearchQuery = query;
this.renderSchedules();
}
toggleScheduleView(view) {
const listView = document.getElementById('schedules-list-view');
const calendarView = document.getElementById('schedules-calendar-view');
if (view === 'calendar') {
listView?.classList.add('hidden');
calendarView?.classList.remove('hidden');
this.renderScheduleCalendar();
} else {
listView?.classList.remove('hidden');
calendarView?.classList.add('hidden');
}
}
async refreshSchedules() {
try {
const [schedulesData, statsData] = await Promise.all([
this.apiCall('/api/schedules'),
this.apiCall('/api/schedules/stats')
]);
this.schedules = schedulesData.schedules || [];
this.schedulesStats = statsData.stats || {};
this.schedulesUpcoming = statsData.upcoming || [];
this.renderSchedules();
this.showNotification('Schedules rafraîchis', 'success');
} catch (error) {
this.showNotification('Erreur lors du rafraîchissement', 'error');
}
}
async refreshSchedulesStats() {
try {
const statsData = await this.apiCall('/api/schedules/stats');
this.schedulesStats = statsData.stats || {};
this.schedulesUpcoming = statsData.upcoming || [];
this.updateSchedulesStats();
} catch (error) {
console.error('Erreur rafraîchissement stats schedules:', error);
}
}
// ===== ACTIONS SCHEDULES =====
async runScheduleNow(scheduleId) {
if (!confirm('Exécuter ce schedule immédiatement ?')) return;
try {
this.showLoading();
await this.apiCall(`/api/schedules/${scheduleId}/run`, { method: 'POST' });
this.showNotification('Schedule lancé', 'success');
} catch (error) {
this.showNotification('Erreur lors du lancement', 'error');
} finally {
this.hideLoading();
}
}
async pauseSchedule(scheduleId) {
try {
const result = await this.apiCall(`/api/schedules/${scheduleId}/pause`, { method: 'POST' });
const schedule = this.schedules.find(s => s.id === scheduleId);
if (schedule) schedule.enabled = false;
this.renderSchedules();
this.showNotification(`Schedule mis en pause`, 'success');
} catch (error) {
this.showNotification('Erreur lors de la mise en pause', 'error');
}
}
async resumeSchedule(scheduleId) {
try {
const result = await this.apiCall(`/api/schedules/${scheduleId}/resume`, { method: 'POST' });
const schedule = this.schedules.find(s => s.id === scheduleId);
if (schedule) schedule.enabled = true;
this.renderSchedules();
this.showNotification(`Schedule repris`, 'success');
} catch (error) {
this.showNotification('Erreur lors de la reprise', 'error');
}
}
async deleteSchedule(scheduleId) {
const schedule = this.schedules.find(s => s.id === scheduleId);
if (!schedule) return;
if (!confirm(`Supprimer le schedule "${schedule.name}" ?`)) return;
try {
await this.apiCall(`/api/schedules/${scheduleId}`, { method: 'DELETE' });
this.schedules = this.schedules.filter(s => s.id !== scheduleId);
this.renderSchedules();
this.showNotification(`Schedule supprimé`, 'success');
} catch (error) {
this.showNotification('Erreur lors de la suppression', 'error');
}
}
// ===== MODAL CRÉATION/ÉDITION SCHEDULE =====
async showCreateScheduleModal(prefilledPlaybook = null) {
this.editingScheduleId = null;
this.scheduleModalStep = 1;
// S'assurer que les playbooks sont chargés
if (!this.playbooks || this.playbooks.length === 0) {
try {
const playbooksData = await this.apiCall('/api/ansible/playbooks');
this.playbooks = playbooksData.playbooks || [];
} catch (error) {
console.error('Erreur chargement playbooks:', error);
}
}
const content = this.getScheduleModalContent(null, prefilledPlaybook);
this.showModal('Nouveau Schedule', content, 'schedule-modal');
}
async showEditScheduleModal(scheduleId) {
const schedule = this.schedules.find(s => s.id === scheduleId);
if (!schedule) return;
this.editingScheduleId = scheduleId;
this.scheduleModalStep = 1;
// S'assurer que les playbooks sont chargés
if (!this.playbooks || this.playbooks.length === 0) {
try {
const playbooksData = await this.apiCall('/api/ansible/playbooks');
this.playbooks = playbooksData.playbooks || [];
} catch (error) {
console.error('Erreur chargement playbooks:', error);
}
}
const content = this.getScheduleModalContent(schedule);
this.showModal(`Modifier: ${schedule.name}`, content, 'schedule-modal');
}
getScheduleModalContent(schedule = null, prefilledPlaybook = null) {
const isEdit = !!schedule;
const s = schedule || {};
// Options de playbooks
const playbookOptions = this.playbooks.map(p =>
`<option value="${p.filename}" ${(s.playbook || prefilledPlaybook) === p.filename ? 'selected' : ''}>${p.name} (${p.filename})</option>`
).join('');
// Options de groupes
const groupOptions = this.ansibleGroups.map(g =>
`<option value="${g}" ${s.target === g ? 'selected' : ''}>${g}</option>`
).join('');
// Options d'hôtes
const hostOptions = this.ansibleHosts.map(h =>
`<option value="${h.name}" ${s.target === h.name ? 'selected' : ''}>${h.name}</option>`
).join('');
// Récurrence
const rec = s.recurrence || {};
const daysChecked = (rec.days || [1]);
return `
<div class="schedule-step-indicator">
<div class="schedule-step-dot active" data-step="1">1</div>
<div class="schedule-step-connector"></div>
<div class="schedule-step-dot" data-step="2">2</div>
<div class="schedule-step-connector"></div>
<div class="schedule-step-dot" data-step="3">3</div>
<div class="schedule-step-connector"></div>
<div class="schedule-step-dot" data-step="4">4</div>
</div>
<!-- Step 1: Informations de base -->
<div class="schedule-modal-step active" data-step="1">
<h4 class="text-lg font-semibold mb-4"><i class="fas fa-info-circle text-purple-400 mr-2"></i>Informations de base</h4>
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Nom du schedule *</label>
<input type="text" id="schedule-name" value="${s.name || ''}"
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
placeholder="Ex: Backup quotidien production">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Description</label>
<textarea id="schedule-description" rows="2"
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
placeholder="Description optionnelle">${s.description || ''}</textarea>
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">Tags</label>
<div class="flex flex-wrap gap-2">
${['Backup', 'Maintenance', 'Monitoring', 'Production', 'Test'].map(tag => `
<label class="flex items-center gap-2 px-3 py-1.5 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700">
<input type="checkbox" value="${tag}" class="schedule-tag-checkbox"
${(s.tags || []).includes(tag) ? 'checked' : ''}>
<span class="text-sm">${tag}</span>
</label>
`).join('')}
</div>
</div>
</div>
<div class="flex justify-end mt-6">
<button onclick="dashboard.scheduleModalNextStep()" class="btn-primary">
Suivant <i class="fas fa-arrow-right ml-2"></i>
</button>
</div>
</div>
<!-- Step 2: Quoi exécuter -->
<div class="schedule-modal-step" data-step="2">
<h4 class="text-lg font-semibold mb-4"><i class="fas fa-play text-blue-400 mr-2"></i>Quoi exécuter ?</h4>
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">Playbook *</label>
<select id="schedule-playbook" class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
<option value="">-- Sélectionner un playbook --</option>
${playbookOptions}
</select>
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">Type de cible</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="schedule-target-type" value="group"
${(s.target_type || 'group') === 'group' ? 'checked' : ''}
onchange="dashboard.toggleScheduleTargetType('group')">
<span>Groupe Ansible</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="schedule-target-type" value="host"
${s.target_type === 'host' ? 'checked' : ''}
onchange="dashboard.toggleScheduleTargetType('host')">
<span>Hôte spécifique</span>
</label>
</div>
</div>
<div id="schedule-group-select" class="${s.target_type === 'host' ? 'hidden' : ''}">
<label class="block text-sm text-gray-400 mb-1">Groupe cible</label>
<select id="schedule-target-group" class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
<option value="all" ${(s.target || 'all') === 'all' ? 'selected' : ''}>all (tous les hôtes)</option>
${groupOptions}
</select>
</div>
<div id="schedule-host-select" class="${s.target_type !== 'host' ? 'hidden' : ''}">
<label class="block text-sm text-gray-400 mb-1">Hôte cible</label>
<select id="schedule-target-host" class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
${hostOptions}
</select>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Timeout (secondes)</label>
<input type="number" id="schedule-timeout" value="${s.timeout || 3600}" min="60" max="86400"
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
</div>
</div>
<div class="flex justify-between mt-6">
<button onclick="dashboard.scheduleModalPrevStep()" class="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600">
<i class="fas fa-arrow-left mr-2"></i>Précédent
</button>
<button onclick="dashboard.scheduleModalNextStep()" class="btn-primary">
Suivant <i class="fas fa-arrow-right ml-2"></i>
</button>
</div>
</div>
<!-- Step 3: Quand -->
<div class="schedule-modal-step" data-step="3">
<h4 class="text-lg font-semibold mb-4"><i class="fas fa-clock text-green-400 mr-2"></i>Quand exécuter ?</h4>
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-2">Type de planification</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="schedule-type" value="recurring"
${(s.schedule_type || 'recurring') === 'recurring' ? 'checked' : ''}
onchange="dashboard.toggleScheduleType('recurring')">
<span>Récurrent</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="schedule-type" value="once"
${s.schedule_type === 'once' ? 'checked' : ''}
onchange="dashboard.toggleScheduleType('once')">
<span>Exécution unique</span>
</label>
</div>
</div>
<!-- Options récurrence -->
<div id="schedule-recurring-options" class="${s.schedule_type === 'once' ? 'hidden' : ''}">
<div class="mb-4">
<label class="block text-sm text-gray-400 mb-1">Fréquence</label>
<select id="schedule-recurrence-type"
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
onchange="dashboard.updateRecurrenceOptions()">
<option value="daily" ${(rec.type || 'daily') === 'daily' ? 'selected' : ''}>Tous les jours</option>
<option value="weekly" ${rec.type === 'weekly' ? 'selected' : ''}>Toutes les semaines</option>
<option value="monthly" ${rec.type === 'monthly' ? 'selected' : ''}>Tous les mois</option>
<option value="custom" ${rec.type === 'custom' ? 'selected' : ''}>Expression cron personnalisée</option>
</select>
</div>
<div id="recurrence-time" class="${rec.type === 'custom' ? 'hidden' : ''}">
<label class="block text-sm text-gray-400 mb-1">Heure d'exécution</label>
<input type="time" id="schedule-time" value="${rec.time || '02:00'}"
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
</div>
<div id="recurrence-weekly-days" class="${rec.type !== 'weekly' ? 'hidden' : ''} mt-4">
<label class="block text-sm text-gray-400 mb-2">Jours de la semaine</label>
<div class="flex flex-wrap gap-2">
${['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day, i) => `
<label class="flex items-center gap-1 px-3 py-1.5 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700">
<input type="checkbox" value="${i+1}" class="schedule-day-checkbox"
${daysChecked.includes(i+1) ? 'checked' : ''}>
<span class="text-sm">${day}</span>
</label>
`).join('')}
</div>
</div>
<div id="recurrence-monthly-day" class="${rec.type !== 'monthly' ? 'hidden' : ''} mt-4">
<label class="block text-sm text-gray-400 mb-1">Jour du mois</label>
<input type="number" id="schedule-day-of-month" value="${rec.day_of_month || 1}" min="1" max="31"
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
</div>
<div id="recurrence-cron" class="${rec.type !== 'custom' ? 'hidden' : ''} mt-4">
<label class="block text-sm text-gray-400 mb-1">Expression cron</label>
<input type="text" id="schedule-cron" value="${rec.cron_expression || '0 2 * * *'}"
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg font-mono"
placeholder="minute heure jour mois jour_semaine"
oninput="dashboard.validateCronExpression(this.value)">
<div class="text-xs text-gray-500 mt-1">
<a href="https://crontab.guru/" target="_blank" class="text-purple-400 hover:underline">
<i class="fas fa-external-link-alt mr-1"></i>Aide crontab.guru
</a>
</div>
<div id="cron-validation" class="mt-2 text-sm"></div>
</div>
</div>
<!-- Options exécution unique -->
<div id="schedule-once-options" class="${s.schedule_type !== 'once' ? 'hidden' : ''}">
<label class="block text-sm text-gray-400 mb-1">Date et heure d'exécution</label>
<input type="datetime-local" id="schedule-start-at"
value="${s.start_at ? s.start_at.slice(0, 16) : ''}"
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
</div>
<div class="mt-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="schedule-enabled" ${(s.enabled !== false) ? 'checked' : ''}>
<span>Activer immédiatement</span>
</label>
</div>
</div>
<div class="flex justify-between mt-6">
<button onclick="dashboard.scheduleModalPrevStep()" class="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600">
<i class="fas fa-arrow-left mr-2"></i>Précédent
</button>
<button onclick="dashboard.scheduleModalNextStep()" class="btn-primary">
Suivant <i class="fas fa-arrow-right ml-2"></i>
</button>
</div>
</div>
<!-- Step 4: Notifications -->
<div class="schedule-modal-step" data-step="4">
<h4 class="text-lg font-semibold mb-4"><i class="fas fa-bell text-yellow-400 mr-2"></i>Notifications</h4>
<div id="ntfy-disabled-warning" class="hidden mb-4 p-3 bg-yellow-900/30 border border-yellow-600 rounded-lg">
<div class="flex items-center gap-2 text-yellow-400">
<i class="fas fa-exclamation-triangle"></i>
<span class="font-medium">Notifications désactivées</span>
</div>
<p class="text-sm text-yellow-300/80 mt-1">Les notifications ntfy sont actuellement désactivées dans la configuration du serveur (NTFY_ENABLED=false). Les paramètres ci-dessous seront ignorés.</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-3">Type de notification</label>
<div class="space-y-3">
<label class="flex items-start gap-3 p-3 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700 border border-transparent has-[:checked]:border-purple-500">
<input type="radio" name="schedule-notification-type" value="all"
${(s.notification_type || 'all') === 'all' ? 'checked' : ''}
class="mt-1">
<div>
<span class="font-medium"><i class="fas fa-bell text-green-400 mr-2"></i>Toujours notifier</span>
<p class="text-sm text-gray-400 mt-1">Recevoir une notification à chaque exécution (succès ou échec)</p>
</div>
</label>
<label class="flex items-start gap-3 p-3 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700 border border-transparent has-[:checked]:border-purple-500">
<input type="radio" name="schedule-notification-type" value="errors"
${s.notification_type === 'errors' ? 'checked' : ''}
class="mt-1">
<div>
<span class="font-medium"><i class="fas fa-exclamation-circle text-red-400 mr-2"></i>Erreurs seulement</span>
<p class="text-sm text-gray-400 mt-1">Recevoir une notification uniquement en cas d'échec</p>
</div>
</label>
<label class="flex items-start gap-3 p-3 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700 border border-transparent has-[:checked]:border-purple-500">
<input type="radio" name="schedule-notification-type" value="none"
${s.notification_type === 'none' ? 'checked' : ''}
class="mt-1">
<div>
<span class="font-medium"><i class="fas fa-bell-slash text-gray-400 mr-2"></i>Aucune notification</span>
<p class="text-sm text-gray-400 mt-1">Ne pas envoyer de notification pour ce schedule</p>
</div>
</label>
</div>
</div>
</div>
<div class="flex justify-between mt-6">
<button onclick="dashboard.scheduleModalPrevStep()" class="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600">
<i class="fas fa-arrow-left mr-2"></i>Précédent
</button>
<button onclick="dashboard.saveSchedule()" class="btn-primary">
<i class="fas fa-save mr-2"></i>${isEdit ? 'Enregistrer' : 'Créer le schedule'}
</button>
</div>
</div>
`;
}
async scheduleModalNextStep() {
if (this.scheduleModalStep < 4) {
this.scheduleModalStep++;
this.updateScheduleModalStep();
// Si on arrive à l'étape 4 (Notifications), vérifier si NTFY est activé
if (this.scheduleModalStep === 4) {
await this.checkNtfyStatus();
}
}
}
async checkNtfyStatus() {
try {
const config = await this.apiCall('/api/notifications/config');
const warningEl = document.getElementById('ntfy-disabled-warning');
if (warningEl) {
warningEl.classList.toggle('hidden', config.enabled !== false);
}
} catch (error) {
console.error('Erreur vérification statut NTFY:', error);
}
}
scheduleModalPrevStep() {
if (this.scheduleModalStep > 1) {
this.scheduleModalStep--;
this.updateScheduleModalStep();
}
}
updateScheduleModalStep() {
// Mettre à jour les indicateurs
document.querySelectorAll('.schedule-step-dot').forEach(dot => {
const step = parseInt(dot.dataset.step);
dot.classList.remove('active', 'completed');
if (step < this.scheduleModalStep) {
dot.classList.add('completed');
} else if (step === this.scheduleModalStep) {
dot.classList.add('active');
}
});
document.querySelectorAll('.schedule-step-connector').forEach((conn, i) => {
conn.classList.toggle('active', i < this.scheduleModalStep - 1);
});
// Afficher l'étape actuelle
document.querySelectorAll('.schedule-modal-step').forEach(step => {
step.classList.remove('active');
if (parseInt(step.dataset.step) === this.scheduleModalStep) {
step.classList.add('active');
}
});
}
toggleScheduleTargetType(type) {
document.getElementById('schedule-group-select')?.classList.toggle('hidden', type === 'host');
document.getElementById('schedule-host-select')?.classList.toggle('hidden', type === 'group');
}
toggleScheduleType(type) {
document.getElementById('schedule-recurring-options')?.classList.toggle('hidden', type === 'once');
document.getElementById('schedule-once-options')?.classList.toggle('hidden', type === 'recurring');
}
updateRecurrenceOptions() {
const type = document.getElementById('schedule-recurrence-type')?.value;
document.getElementById('recurrence-time')?.classList.toggle('hidden', type === 'custom');
document.getElementById('recurrence-weekly-days')?.classList.toggle('hidden', type !== 'weekly');
document.getElementById('recurrence-monthly-day')?.classList.toggle('hidden', type !== 'monthly');
document.getElementById('recurrence-cron')?.classList.toggle('hidden', type !== 'custom');
}
async validateCronExpression(expression) {
const container = document.getElementById('cron-validation');
if (!container || !expression.trim()) {
if (container) container.innerHTML = '';
return;
}
try {
const result = await this.apiCall(`/api/schedules/validate-cron?expression=${encodeURIComponent(expression)}`);
if (result.valid) {
container.innerHTML = `
<div class="text-green-400"><i class="fas fa-check mr-1"></i>Expression valide</div>
<div class="text-xs text-gray-500 mt-1">Prochaines: ${result.next_runs?.slice(0, 3).map(r => new Date(r).toLocaleString('fr-FR')).join(', ')}</div>
`;
} else {
container.innerHTML = `<div class="text-red-400"><i class="fas fa-times mr-1"></i>${result.error}</div>`;
}
} catch (error) {
container.innerHTML = `<div class="text-red-400"><i class="fas fa-times mr-1"></i>Erreur de validation</div>`;
}
}
async saveSchedule() {
const name = document.getElementById('schedule-name')?.value.trim();
const description = document.getElementById('schedule-description')?.value.trim();
const playbook = document.getElementById('schedule-playbook')?.value;
const targetType = document.querySelector('input[name="schedule-target-type"]:checked')?.value || 'group';
const targetGroup = document.getElementById('schedule-target-group')?.value;
const targetHost = document.getElementById('schedule-target-host')?.value;
const timeout = parseInt(document.getElementById('schedule-timeout')?.value) || 3600;
const scheduleType = document.querySelector('input[name="schedule-type"]:checked')?.value || 'recurring';
const enabled = document.getElementById('schedule-enabled')?.checked ?? true;
const notificationType = document.querySelector('input[name="schedule-notification-type"]:checked')?.value || 'all';
// Validation
if (!name || name.length < 3) {
this.showNotification('Le nom doit faire au moins 3 caractères', 'error');
return;
}
if (!playbook) {
this.showNotification('Veuillez sélectionner un playbook', 'error');
return;
}
// Construire les tags
const tags = Array.from(document.querySelectorAll('.schedule-tag-checkbox:checked')).map(cb => cb.value);
// Construire la récurrence
let recurrence = null;
let startAt = null;
if (scheduleType === 'recurring') {
const recType = document.getElementById('schedule-recurrence-type')?.value || 'daily';
const time = document.getElementById('schedule-time')?.value || '02:00';
recurrence = { type: recType, time };
if (recType === 'weekly') {
recurrence.days = Array.from(document.querySelectorAll('.schedule-day-checkbox:checked')).map(cb => parseInt(cb.value));
if (recurrence.days.length === 0) recurrence.days = [1];
} else if (recType === 'monthly') {
recurrence.day_of_month = parseInt(document.getElementById('schedule-day-of-month')?.value) || 1;
} else if (recType === 'custom') {
recurrence.cron_expression = document.getElementById('schedule-cron')?.value;
if (!recurrence.cron_expression) {
this.showNotification('Veuillez entrer une expression cron', 'error');
return;
}
}
} else {
const startAtValue = document.getElementById('schedule-start-at')?.value;
if (startAtValue) {
startAt = new Date(startAtValue).toISOString();
}
}
const payload = {
name,
description: description || null,
playbook,
target_type: targetType,
target: targetType === 'host' ? targetHost : targetGroup,
timeout,
schedule_type: scheduleType,
recurrence,
start_at: startAt,
enabled,
tags,
notification_type: notificationType
};
try {
this.showLoading();
if (this.editingScheduleId) {
await this.apiCall(`/api/schedules/${this.editingScheduleId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
await this.apiCall('/api/schedules', { method: 'POST', body: JSON.stringify(payload) });
}
this.closeModal();
await this.refreshSchedules();
} catch (error) {
this.showNotification(error.message || 'Erreur lors de la sauvegarde', 'error');
} finally {
this.hideLoading();
}
}
async showScheduleHistory(scheduleId) {
const schedule = this.schedules.find(s => s.id === scheduleId);
if (!schedule) return;
try {
const result = await this.apiCall(`/api/schedules/${scheduleId}/runs?limit=50`);
const runs = result.runs || [];
let content;
if (runs.length === 0) {
content = `
<div class="text-center py-8 text-gray-400">
<i class="fas fa-clock text-4xl mb-4"></i>
<p>Aucune exécution enregistrée</p>
<p class="text-sm">Le schedule n'a pas encore été exécuté.</p>
</div>
`;
} else {
content = `
<div class="mb-4 text-sm text-gray-400">
${runs.length} exécution(s) - Taux de succès: ${schedule.run_count > 0 ? Math.round((schedule.success_count / schedule.run_count) * 100) : 0}%
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
${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 `
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-3">
<span class="schedule-status-chip ${statusClass}">
<i class="fas fa-${statusIcon} mr-1"></i>${run.status}
</span>
<div>
<div class="text-sm">${startedAt.toLocaleString('fr-FR')}</div>
${run.duration_seconds ? `<div class="text-xs text-gray-500">Durée: ${run.duration_seconds.toFixed(1)}s</div>` : ''}
</div>
</div>
<div class="text-right text-sm">
${run.hosts_impacted > 0 ? `<span class="text-gray-400">${run.hosts_impacted} hôte(s)</span>` : ''}
${run.task_id ? `<a href="#" onclick="dashboard.showTaskDetail(${run.task_id})" class="text-purple-400 hover:underline ml-2">Voir tâche</a>` : ''}
</div>
</div>
`;
}).join('')}
</div>
`;
}
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 `
<div class="${classes.join(' ')}" data-date="${dateStr}">
<div class="text-xs font-semibold mb-1 ${day.otherMonth ? 'text-gray-600' : 'text-gray-300'}">
${day.date.getDate()}
</div>
${events.slice(0, 2).map(e => `
<div class="schedule-calendar-event scheduled" title="${e.schedule_name}">
${e.schedule_name}
</div>
`).join('')}
${events.length > 2 ? `<div class="text-xs text-gray-500">+${events.length - 2}</div>` : ''}
</div>
`;
}).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 = `
<div class="flex items-center space-x-3">
<i class="fas ${
type === 'success' ? 'fa-check-circle' :
type === 'warning' ? 'fa-exclamation-triangle' :
type === 'error' ? 'fa-exclamation-circle' : 'fa-info-circle'
}"></i>
<span>${message}</span>
</div>
`;
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);
}
};