// 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 = '
En cours ';
// Insérer au début du container (après le header)
const header = container.querySelector('.flex.flex-col');
if (header && header.nextSibling) {
container.insertBefore(runningSection, header.nextSibling);
} else {
container.prepend(runningSection);
}
}
// Mettre à jour le contenu des tâches en cours
const tasksContainer = runningSection.querySelector('.running-tasks-list') || document.createElement('div');
tasksContainer.className = 'running-tasks-list space-y-2';
tasksContainer.innerHTML = runningTasks.map(task => this.createRunningTaskHTML(task)).join('');
if (!runningSection.querySelector('.running-tasks-list')) {
runningSection.appendChild(tasksContainer);
}
// Mettre à jour le badge "en cours" dans le header
const runningBadge = container.querySelector('.running-badge');
if (runningBadge) {
runningBadge.textContent = `${runningTasks.length} en cours`;
}
}
createRunningTaskHTML(task) {
const startTime = task.start_time
? new Date(task.start_time).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: '--';
const duration = task.duration || this.calculateDuration(task.start_time);
const progress = task.progress || 0;
return `
${this.escapeHtml(task.name)}
En cours
Cible: ${this.escapeHtml(task.host)}
Début: ${startTime} • Durée: ${duration}
${progress}% complété
`;
}
calculateDuration(startTime) {
if (!startTime) return '--';
const start = new Date(startTime);
const now = new Date();
const diffMs = now - start;
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s`;
const diffMin = Math.floor(diffSec / 60);
const remainingSec = diffSec % 60;
if (diffMin < 60) return `${diffMin}m ${remainingSec}s`;
const diffHour = Math.floor(diffMin / 60);
const remainingMin = diffMin % 60;
return `${diffHour}h ${remainingMin}m`;
}
// ===== HANDLERS WEBSOCKET POUR LES TÂCHES =====
handleTaskCreated(taskData) {
console.log('Nouvelle tâche créée:', taskData);
// Ajouter la tâche à la liste
const existingIndex = this.tasks.findIndex(t => t.id === taskData.id);
if (existingIndex === -1) {
this.tasks.push(taskData);
} else {
this.tasks[existingIndex] = taskData;
}
// Mettre à jour l'UI immédiatement
this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending'));
this.updateTaskCounts();
// Notification
this.showNotification(`Tâche "${taskData.name}" démarrée`, 'info');
}
handleTaskProgress(progressData) {
console.log('Progression tâche:', progressData);
// Mettre à jour la tâche dans la liste
const task = this.tasks.find(t => t.id === progressData.task_id);
if (task) {
task.progress = progressData.progress;
// Mettre à jour l'UI de cette tâche spécifique
const taskCard = document.querySelector(`.task-card-${progressData.task_id}`);
if (taskCard) {
const progressBar = taskCard.querySelector('.bg-blue-500');
const progressText = taskCard.querySelector('.text-gray-500.mt-1');
if (progressBar) {
progressBar.style.width = `${progressData.progress}%`;
}
if (progressText) {
progressText.textContent = `${progressData.progress}% complété`;
}
}
}
}
handleTaskCompleted(taskData) {
console.log('Tâche terminée:', taskData);
// Retirer la tâche de la liste des tâches en cours
this.tasks = this.tasks.filter(t => t.id !== taskData.task_id);
// Mettre à jour l'UI
this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending'));
// Rafraîchir les logs de tâches pour voir la tâche terminée
this.refreshTaskLogs();
// Notification
const status = taskData.status || 'completed';
const isSuccess = status === 'completed';
this.showNotification(
`Tâche terminée: ${isSuccess ? 'Succès' : 'Échec'}`,
isSuccess ? 'success' : 'error'
);
}
handleTaskCancelled(taskData) {
console.log('Tâche annulée:', taskData);
// Retirer la tâche de la liste des tâches en cours
this.tasks = this.tasks.filter(t => String(t.id) !== String(taskData.id));
// Mettre à jour l'UI
this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending'));
// Rafraîchir les logs de tâches
this.refreshTaskLogs();
// Notification
this.showNotification('Tâche annulée', 'warning');
}
async loadLogs() {
try {
const logsData = await this.apiCall('/api/logs');
this.logs = logsData;
this.renderLogs();
} catch (error) {
console.error('Erreur chargement logs:', error);
}
}
setupEventListeners() {
// Theme toggle
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
this.toggleTheme();
});
}
// Initialiser le calendrier de filtrage des tâches
this.setupTaskDateCalendar();
// Navigation est gérée par le script de navigation des pages dans index.html
}
// ===== CALENDRIER DE FILTRAGE DES TÂCHES =====
setupTaskDateCalendar() {
const wrapper = document.getElementById('task-date-filter-wrapper');
const button = document.getElementById('task-date-filter-button');
const calendar = document.getElementById('task-date-calendar');
const prevBtn = document.getElementById('task-cal-prev-month');
const nextBtn = document.getElementById('task-cal-next-month');
const clearBtn = document.getElementById('task-cal-clear');
const applyBtn = document.getElementById('task-cal-apply');
if (!wrapper || !button || !calendar) {
return; // Section tâches pas présente
}
// État initial
this.taskCalendarMonth = new Date();
this.selectedTaskDates = this.selectedTaskDates || [];
const toggleCalendar = (open) => {
const shouldOpen = typeof open === 'boolean' ? open : calendar.classList.contains('hidden');
if (shouldOpen) {
calendar.classList.remove('hidden');
this.renderTaskCalendar();
} else {
calendar.classList.add('hidden');
}
};
button.addEventListener('click', (event) => {
event.stopPropagation();
toggleCalendar();
});
document.addEventListener('click', (event) => {
if (!wrapper.contains(event.target)) {
calendar.classList.add('hidden');
}
});
prevBtn?.addEventListener('click', (event) => {
event.stopPropagation();
this.changeTaskCalendarMonth(-1);
});
nextBtn?.addEventListener('click', (event) => {
event.stopPropagation();
this.changeTaskCalendarMonth(1);
});
clearBtn?.addEventListener('click', (event) => {
event.stopPropagation();
this.selectedTaskDates = [];
this.updateDateFilters();
this.renderTaskCalendar();
});
applyBtn?.addEventListener('click', (event) => {
event.stopPropagation();
this.applyDateFilter();
calendar.classList.add('hidden');
});
// Premier rendu
this.updateDateFilters();
this.renderTaskCalendar();
}
changeTaskCalendarMonth(delta) {
const base = this.taskCalendarMonth instanceof Date ? this.taskCalendarMonth : new Date();
const d = new Date(base);
d.setMonth(d.getMonth() + delta);
this.taskCalendarMonth = d;
this.renderTaskCalendar();
}
renderTaskCalendar() {
const grid = document.getElementById('task-cal-grid');
const monthLabel = document.getElementById('task-cal-current-month');
if (!grid || !monthLabel) return;
const base = this.taskCalendarMonth instanceof Date ? this.taskCalendarMonth : new Date();
const year = base.getFullYear();
const month = base.getMonth();
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
monthLabel.textContent = `${monthNames[month]} ${year}`;
grid.innerHTML = '';
const firstDayOfMonth = new Date(year, month, 1);
const firstDayOfWeek = firstDayOfMonth.getDay(); // 0 (dimanche) - 6 (samedi)
const daysInMonth = new Date(year, month + 1, 0).getDate();
const prevMonthLastDay = new Date(year, month, 0).getDate();
const totalCells = 42; // 6 lignes * 7 colonnes
for (let i = 0; i < totalCells; i++) {
const cell = document.createElement('div');
cell.className = 'flex justify-center items-center py-0.5';
let date;
if (i < firstDayOfWeek) {
// Jours du mois précédent
const day = prevMonthLastDay - (firstDayOfWeek - 1 - i);
date = new Date(year, month - 1, day);
} else if (i < firstDayOfWeek + daysInMonth) {
// Jours du mois courant
const day = i - firstDayOfWeek + 1;
date = new Date(year, month, day);
} else {
// Jours du mois suivant
const day = i - (firstDayOfWeek + daysInMonth) + 1;
date = new Date(year, month + 1, day);
}
const btn = document.createElement('button');
btn.type = 'button';
const key = this.getDateKey(date);
const isCurrentMonth = date.getMonth() === month;
const isSelected = this.selectedTaskDates.includes(key);
const today = new Date();
today.setHours(0, 0, 0, 0);
const isToday = date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
let classes = 'w-9 h-9 flex items-center justify-center rounded-full text-xs transition-colors duration-150 ';
if (!isCurrentMonth) {
classes += 'text-gray-600';
btn.disabled = true;
} else {
btn.dataset.date = key;
if (isSelected) {
classes += 'bg-purple-600 text-white hover:bg-purple-500 cursor-pointer';
} else if (isToday) {
classes += 'border border-purple-400 text-purple-200 hover:bg-gray-800 cursor-pointer';
} else {
classes += 'text-gray-200 hover:bg-gray-800 cursor-pointer';
}
btn.addEventListener('click', (event) => {
event.stopPropagation();
this.toggleTaskDateSelection(key);
});
}
btn.className = classes;
btn.textContent = String(date.getDate());
cell.appendChild(btn);
grid.appendChild(cell);
}
}
toggleTaskDateSelection(key) {
const index = this.selectedTaskDates.indexOf(key);
if (index > -1) {
this.selectedTaskDates.splice(index, 1);
} else {
this.selectedTaskDates.push(key);
}
// Garder les dates triées
this.selectedTaskDates.sort();
this.updateDateFilters();
this.renderTaskCalendar();
}
getDateKey(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
parseDateKey(key) {
const [y, m, d] = key.split('-').map(v => parseInt(v, 10));
return new Date(y, (m || 1) - 1, d || 1);
}
toggleTheme() {
const body = document.body;
const currentTheme = body.classList.contains('light-theme') ? 'light' : 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
if (newTheme === 'light') {
body.classList.add('light-theme');
document.getElementById('theme-toggle').innerHTML = ' ';
} else {
body.classList.remove('light-theme');
document.getElementById('theme-toggle').innerHTML = ' ';
}
// Persist theme preference
localStorage.setItem('theme', newTheme);
}
loadThemePreference() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'light') {
document.body.classList.add('light-theme');
document.getElementById('theme-toggle').innerHTML = ' ';
}
}
renderHosts() {
const container = document.getElementById('hosts-list');
const hostsPageContainer = document.getElementById('hosts-page-list');
if (!container && !hostsPageContainer) return;
// Filtrer les hôtes par groupe si un filtre est actif
let filteredHosts = this.hosts;
if (this.currentGroupFilter && this.currentGroupFilter !== 'all') {
filteredHosts = this.hosts.filter(h =>
h.groups && h.groups.includes(this.currentGroupFilter)
);
}
// Filtrer par statut bootstrap si un filtre est actif
if (this.currentBootstrapFilter && this.currentBootstrapFilter !== 'all') {
if (this.currentBootstrapFilter === 'ready') {
filteredHosts = filteredHosts.filter(h => h.bootstrap_ok);
} else if (this.currentBootstrapFilter === 'not_configured') {
filteredHosts = filteredHosts.filter(h => !h.bootstrap_ok);
}
}
// Compter les hôtes par statut bootstrap
const readyCount = this.hosts.filter(h => h.bootstrap_ok).length;
const notConfiguredCount = this.hosts.filter(h => !h.bootstrap_ok).length;
// Options des groupes pour le filtre
const groupOptions = this.ansibleGroups.map(g =>
`${g} `
).join('');
// Header avec filtres et boutons - Design professionnel
const headerHtml = `
${filteredHosts.length}/${this.hosts.length} hôtes
${readyCount} Ready
${notConfiguredCount} Non configuré
Playbook
Rafraîchir
Filtres:
Tous
Ansible Ready
Non configuré
Tous les groupes
${groupOptions}
`;
// Apply to both containers
const containers = [container, hostsPageContainer].filter(c => c);
containers.forEach(c => c.innerHTML = headerHtml);
if (filteredHosts.length === 0) {
const emptyHtml = `
Aucun hôte trouvé ${this.currentGroupFilter !== 'all' ? `dans le groupe "${this.currentGroupFilter}"` : ''} ${this.currentBootstrapFilter && this.currentBootstrapFilter !== 'all' ? `avec le statut "${this.currentBootstrapFilter === 'ready' ? 'Ansible Ready' : 'Non configuré'}"` : ''}
Ajouter un hôte
`;
containers.forEach(c => c.innerHTML += emptyHtml);
return;
}
filteredHosts.forEach(host => {
const statusClass = `status-${host.status}`;
// Formater last_seen
const lastSeen = host.last_seen
? new Date(host.last_seen).toLocaleString('fr-FR')
: 'Jamais vérifié';
// Indicateur de bootstrap
const bootstrapOk = host.bootstrap_ok || false;
const bootstrapDate = host.bootstrap_date
? new Date(host.bootstrap_date).toLocaleDateString('fr-FR')
: null;
const bootstrapIndicator = bootstrapOk
? `
Ansible Ready
`
: `
Non configuré
`;
// Indicateur de qualité de communication
const commQuality = this.getHostCommunicationQuality(host);
const commIndicator = `
${[1,2,3,4,5].map(i => `
`).join('')}
${commQuality.label}
`;
const hostCard = document.createElement('div');
hostCard.className = 'host-card group';
// Séparer les groupes env et role
const hostGroups = host.groups || [];
const envGroup = hostGroups.find(g => g.startsWith('env_'));
const roleGroups = hostGroups.filter(g => g.startsWith('role_'));
const envBadge = envGroup
? `${envGroup.replace('env_', '')} `
: '';
const roleBadges = roleGroups.map(g =>
`${g.replace('role_', '')} `
).join('');
hostCard.innerHTML = `
${host.name}
${bootstrapIndicator}
${host.ip} • ${host.os}${envGroup ? ` (${envGroup})` : ''}
${roleBadges}
${commIndicator}
${lastSeen}
Health Check
Upgrade
Reboot
Backup
Bootstrap
Playbook
`;
// 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 => `
${p.name}${p.description ? ` - ${p.description}` : ''}
`).join('');
const modalContent = `
Hôte cible
${this.escapeHtml(hostName)}
Seuls les playbooks compatibles avec cet hôte sont affichés (${playbooks.length} disponible${playbooks.length > 1 ? 's' : ''})
Playbook à exécuter
-- Sélectionner un playbook --
${playbookOptions}
Variables supplémentaires (JSON, optionnel)
Mode simulation (--check)
Annuler
Exécuter
`;
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 =>
`${g.replace('env_', '')} `
).join('');
const roleCheckboxes = this.roleGroups.map(g => `
${g.replace('role_', '')}
`).join('');
this.showModal('Ajouter un Host', `
`);
}
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 =>
`${g.replace('env_', '')} `
).join('');
const roleCheckboxes = this.roleGroups.map(g => `
${g.replace('role_', '')}
`).join('');
this.showModal(`Modifier: ${hostName}`, `
`);
}
async updateHost(event, hostName) {
event.preventDefault();
const formData = new FormData(event.target);
// Récupérer les rôles sélectionnés
const roleGroups = [];
document.querySelectorAll('input[name="role_groups"]:checked').forEach(cb => {
roleGroups.push(cb.value);
});
const payload = {
env_group: formData.get('env_group') || null,
role_groups: roleGroups,
ansible_host: formData.get('ansible_host') || null
};
this.closeModal();
this.showLoading();
try {
const result = await this.apiCall(`/api/hosts/${encodeURIComponent(hostName)}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
this.hideLoading();
this.showNotification(`Hôte "${hostName}" mis à jour avec succès`, 'success');
// Recharger les données
await this.loadAllData();
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur: ${error.message}`, 'error');
}
}
confirmDeleteHost(hostName) {
this.showModal('Confirmer la suppression', `
Attention !
Vous êtes sur le point de supprimer l'hôte ${hostName} de l'inventaire Ansible.
Cette action supprimera l'hôte de tous les groupes et ne peut pas être annulée.
Supprimer définitivement
Annuler
`);
}
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}`, `
`);
}
async createGroup(event, type) {
event.preventDefault();
const formData = new FormData(event.target);
const payload = {
name: formData.get('name'),
type: type
};
this.closeModal();
this.showLoading();
try {
const result = await this.apiCall('/api/groups', {
method: 'POST',
body: JSON.stringify(payload)
});
this.hideLoading();
this.showNotification(result.message || `Groupe créé avec succès`, 'success');
// Recharger les groupes
await this.loadHostGroups();
await this.loadAllData();
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur: ${error.message}`, 'error');
}
}
async showManageGroupsModal(type) {
const typeLabel = type === 'env' ? 'environnement' : 'rôle';
const typeLabelPlural = type === 'env' ? 'environnements' : 'rôles';
const icon = type === 'env' ? 'fa-globe' : 'fa-tags';
const color = type === 'env' ? 'green' : 'blue';
// Charger les groupes
const groupsData = await this.loadGroups();
const groups = groupsData.groups.filter(g => g.type === type);
let groupsHtml = '';
if (groups.length === 0) {
groupsHtml = `
Aucun groupe d'${typeLabel} trouvé
Créer un groupe
`;
} else {
groupsHtml = `
${groups.map(g => `
${g.display_name}
${g.name}
${g.hosts_count} hôte(s)
`).join('')}
`;
}
this.showModal(`Gérer les ${typeLabelPlural}`, `
${groups.length} groupe(s) d'${typeLabel}
Ajouter
${groupsHtml}
Fermer
`);
}
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}`, `
`);
}
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 = `
Déplacer les hôtes vers:
${otherGroups.length > 0
? otherGroups.map(g => `${g.display_name} `).join('')
: 'Aucun autre groupe disponible '
}
Les ${hostsCount} hôte(s) seront déplacés vers ce groupe.
`;
}
this.showModal('Confirmer la suppression', `
Attention !
Vous êtes sur le point de supprimer le groupe d'${typeLabel} ${displayName} .
${hostsCount > 0 ? `
Ce groupe contient ${hostsCount} hôte(s).
` : ''}
${moveOptions}
0 && type === 'env' && otherGroups.length === 0 ? 'disabled class="opacity-50 cursor-not-allowed"' : ''}>
Supprimer
Annuler
`);
}
async deleteGroup(groupName, type) {
// Récupérer le groupe de destination si spécifié
const moveSelect = document.getElementById('move-hosts-to');
const moveHostsTo = moveSelect ? moveSelect.value : null;
this.closeModal();
this.showLoading();
try {
let url = `/api/groups/${encodeURIComponent(groupName)}`;
if (moveHostsTo) {
url += `?move_hosts_to=${encodeURIComponent(moveHostsTo)}`;
}
const result = await this.apiCall(url, {
method: 'DELETE'
});
this.hideLoading();
this.showNotification(result.message || `Groupe supprimé avec succès`, 'success');
// Recharger les groupes et afficher la liste
await this.loadHostGroups();
await this.loadAllData();
this.showManageGroupsModal(type);
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur: ${error.message}`, 'error');
}
}
async executePlaybookOnGroup() {
const currentGroup = this.currentGroupFilter;
// Charger les playbooks compatibles avec ce groupe
let compatiblePlaybooks = [];
try {
const pbResult = await this.apiCall(`/api/ansible/playbooks?target=${encodeURIComponent(currentGroup)}`);
compatiblePlaybooks = (pbResult && pbResult.playbooks) ? pbResult.playbooks : [];
} catch (error) {
this.showNotification(`Erreur chargement playbooks: ${error.message}`, 'error');
return;
}
// Générer la liste des playbooks groupés par catégorie
const categoryColors = {
'maintenance': 'text-orange-400',
'monitoring': 'text-green-400',
'backup': 'text-blue-400',
'general': 'text-gray-400',
'testing': 'text-purple-400'
};
let playbooksByCategory = {};
compatiblePlaybooks.forEach(pb => {
const cat = pb.category || 'general';
if (!playbooksByCategory[cat]) playbooksByCategory[cat] = [];
playbooksByCategory[cat].push(pb);
});
let playbooksHtml = '';
Object.entries(playbooksByCategory).forEach(([category, playbooks]) => {
const colorClass = categoryColors[category] || 'text-gray-400';
playbooksHtml += `
${category}
${playbooks.map(pb => `
${pb.name}
${pb.description ? `
${pb.description}
` : ''}
${pb.subcategory || 'other'}
`).join('')}
`;
});
this.showModal(`Exécuter un Playbook sur "${currentGroup === 'all' ? 'Tous les hôtes' : currentGroup}"`, `
Sélectionnez un playbook à exécuter sur ${currentGroup === 'all' ? 'tous les hôtes' : 'le groupe ' + currentGroup}
Seuls les playbooks compatibles avec ce ${currentGroup === 'all' ? 'groupe' : 'groupe'} sont affichés (${compatiblePlaybooks.length} disponible${compatiblePlaybooks.length > 1 ? 's' : ''})
${playbooksHtml || '
Aucun playbook disponible
'}
Annuler
`);
}
async runPlaybookOnTarget(playbook, target) {
this.closeModal();
this.showLoading();
try {
const result = await this.apiCall('/api/ansible/execute', {
method: 'POST',
body: JSON.stringify({
playbook: playbook,
target: target === 'all' ? 'all' : target,
check_mode: false,
verbose: true
})
});
this.hideLoading();
const statusColor = result.success ? 'bg-green-900/30 border-green-600' : 'bg-red-900/30 border-red-600';
const statusIcon = result.success ? 'fa-check-circle text-green-500' : 'fa-times-circle text-red-500';
this.showModal(`Résultat: ${playbook}`, `
${result.success ? 'Exécution réussie' : 'Échec de l\'exécution'}
Durée: ${result.execution_time}s
${this.escapeHtml(result.stdout || '(pas de sortie)')}
${result.stderr ? `
Erreurs:
${this.escapeHtml(result.stderr)}
` : ''}
Fermer
`);
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 =>
`${cat} `
).join('');
// Générer les options de sous-catégories selon la catégorie sélectionnée
let subcategoryOptions = '';
if (this.currentCategoryFilter !== 'all' && this.playbookCategories[this.currentCategoryFilter]) {
subcategoryOptions = this.playbookCategories[this.currentCategoryFilter].map(sub =>
`${sub} `
).join('');
}
// Générer les options de target (groupes + hôtes)
const groupOptions = this.ansibleGroups.map(g =>
`${g} (groupe) `
).join('');
const hostOptions = this.hosts.map(h =>
`${h.name} `
).join('');
// Catégories dynamiques pour le filtre
const taskCategories = ['Playbook', 'Ad-hoc', 'Autre'];
const taskCategoryOptions = taskCategories.map(cat =>
`${cat} `
).join('');
// Types de source pour le filtre
const sourceTypes = [
{ value: 'scheduled', label: 'Planifiés' },
{ value: 'manual', label: 'Manuels' },
{ value: 'adhoc', label: 'Ad-hoc' }
];
const sourceTypeOptions = sourceTypes.map(st =>
`${st.label} `
).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 ? `
Filtres actifs:
${this.currentTargetFilter && this.currentTargetFilter !== 'all' ? `
${this.escapeHtml(this.currentTargetFilter)}
` : ''}
${this.currentCategoryFilter && this.currentCategoryFilter !== 'all' ? `
${this.escapeHtml(this.currentCategoryFilter)}
` : ''}
${this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all' ? `
${sourceTypeLabels[this.currentSourceTypeFilter] || this.currentSourceTypeFilter}
` : ''}
${this.currentHourStart || this.currentHourEnd ? `
${this.currentHourStart || '00:00'} - ${this.currentHourEnd || '23:59'}
` : ''}
Tout effacer
` : '';
// Header avec filtres de catégorie, target et bouton console
const headerHtml = `
${filteredLogs.length} log(s)
${runningTasks.length > 0 ? `
${runningTasks.length} en cours
` : ''}
Toutes cibles
${groupOptions}
${hostOptions}
Toutes catégories
${taskCategoryOptions}
Tous types
${sourceTypeOptions}
Console Ad-Hoc
${activeFiltersHtml}
`;
container.innerHTML = headerHtml;
// Afficher d'abord les tâches en cours (section dynamique)
if (runningTasks.length > 0) {
const runningSection = document.createElement('div');
runningSection.className = 'running-tasks-section mb-4';
runningSection.innerHTML = ' En cours ';
const tasksContainer = document.createElement('div');
tasksContainer.className = 'running-tasks-list space-y-2';
tasksContainer.innerHTML = runningTasks.map(task => this.createRunningTaskHTML(task)).join('');
runningSection.appendChild(tasksContainer);
container.appendChild(runningSection);
}
// Afficher les logs markdown
if (filteredLogs.length > 0) {
const logsSection = document.createElement('div');
logsSection.id = 'task-logs-section';
logsSection.innerHTML = ' Historique des tâches ';
// 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 = `
Charger plus (${remaining} restantes)
`;
} else {
paginationEl.classList.add('hidden');
}
}
} else if (runningTasks.length === 0) {
container.innerHTML += `
Aucune tâche trouvée
Utilisez "Actions Rapides" ou la Console pour lancer une commande
`;
}
}
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 = `
Charger plus (${remaining} restantes)
`;
} 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': ' ',
'failed': ' ',
'running': ' ',
'pending': ' '
};
// Formater les heures de début et fin
const formatTime = (isoString) => {
if (!isoString || isoString === 'N/A') return null;
try {
const date = new Date(isoString);
if (isNaN(date.getTime())) return null;
return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return null;
}
};
// Formater la durée
const formatDuration = (seconds) => {
if (!seconds || seconds <= 0) return null;
if (seconds < 60) return `${seconds} seconde${seconds > 1 ? 's' : ''}`;
if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return secs > 0 ? `${mins} minute${mins > 1 ? 's' : ''} ${secs} seconde${secs > 1 ? 's' : ''}` : `${mins} minute${mins > 1 ? 's' : ''}`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
let result = `${hours} heure${hours > 1 ? 's' : ''}`;
if (mins > 0) result += ` ${mins} minute${mins > 1 ? 's' : ''}`;
if (secs > 0) result += ` ${secs} seconde${secs > 1 ? 's' : ''}`;
return result;
};
const startTime = formatTime(log.start_time);
const endTime = formatTime(log.end_time);
const duration = log.duration_seconds ? formatDuration(log.duration_seconds) : (log.duration && log.duration !== 'N/A' ? log.duration : null);
// Générer les badges d'hôtes
const hostsHtml = log.hosts && log.hosts.length > 0
? `
${log.hosts.slice(0, 8).map(host => `
${this.escapeHtml(host)}
`).join('')}
${log.hosts.length > 8 ? `+${log.hosts.length - 8} autres ` : ''}
`
: '';
// Badge de catégorie
const categoryBadge = log.category
? `
${this.escapeHtml(log.category)}${log.subcategory ? ` / ${this.escapeHtml(log.subcategory)}` : ''}
`
: '';
// Cible cliquable
const targetHtml = log.target
? `
${this.escapeHtml(log.target)}
`
: '';
const card = document.createElement('div');
card.className = `host-card border-l-4 ${statusColors[log.status] || 'border-gray-500'} cursor-pointer hover:bg-opacity-20 transition-all`;
card.onclick = () => this.viewTaskLogContent(log.id);
card.innerHTML = `
${statusIcons[log.status] || ' '}
${this.escapeHtml(log.task_name)}
${this.getStatusBadge(log.status)}
${categoryBadge}
${log.date}
${log.target ? ` ${targetHtml} ` : ''}
${startTime ? ` ${startTime} ` : ''}
${endTime ? ` ${endTime} ` : ''}
${duration ? ` ${duration} ` : ''}
${hostsHtml}
`;
return card;
}
// Nouvelles fonctions de filtrage par clic
filterByHost(host) {
this.currentTargetFilter = host;
this.tasksDisplayedCount = this.tasksPerPage;
this.loadTaskLogsWithFilters();
this.showNotification(`Filtre appliqué: ${host}`, 'info');
}
filterByTarget(target) {
this.currentTargetFilter = target;
this.tasksDisplayedCount = this.tasksPerPage;
this.loadTaskLogsWithFilters();
this.showNotification(`Filtre appliqué: ${target}`, 'info');
}
filterByCategory(category) {
this.currentCategoryFilter = category;
this.currentSubcategoryFilter = 'all';
this.tasksDisplayedCount = this.tasksPerPage;
this.loadTaskLogsWithFilters();
this.showNotification(`Filtre catégorie: ${category}`, 'info');
}
async viewTaskLogContent(logId) {
try {
const result = await this.apiCall(`/api/tasks/logs/${logId}`);
const parsed = this.parseTaskLogMarkdown(result.content);
// Détecter si c'est une sortie de playbook structurée
const isPlaybookOutput = parsed.output && (
parsed.output.includes('PLAY [') ||
parsed.output.includes('TASK [') ||
parsed.output.includes('PLAY RECAP')
);
// Si c'est un playbook, utiliser la vue structurée
if (isPlaybookOutput) {
const parsedPlaybook = this.parseAnsiblePlaybookOutput(parsed.output);
this.currentParsedOutput = parsedPlaybook;
this.currentTaskLogRawOutput = parsed.output;
// Mémoriser les métadonnées et le titre pour pouvoir revenir au résumé
this.currentStructuredPlaybookMetadata = {
duration: parsed.duration || 'N/A',
date: result.log.date || '',
target: result.log.target || parsed.target || 'N/A'
};
this.currentStructuredPlaybookTitle = `Log: ${result.log.task_name}`;
this.showStructuredPlaybookViewModal();
return;
}
// Sinon, utiliser la vue structurée ad-hoc (similaire aux playbooks)
const hostOutputs = this.parseOutputByHost(parsed.output);
this.currentTaskLogHostOutputs = hostOutputs;
// Compter les succès/échecs
const successCount = hostOutputs.filter(h => h.status === 'changed' || h.status === 'success').length;
const failedCount = hostOutputs.filter(h => h.status === 'failed' || h.status === 'unreachable').length;
const totalHosts = hostOutputs.length;
// Utiliser la vue structurée ad-hoc si plusieurs hôtes
if (totalHosts > 0) {
const isSuccess = result.log.status === 'completed';
const adHocView = this.renderAdHocStructuredView(hostOutputs, {
taskName: result.log.task_name,
target: result.log.target || parsed.target || 'N/A',
duration: parsed.duration || 'N/A',
returnCode: parsed.returnCode,
date: result.log.date || '',
isSuccess: isSuccess,
error: parsed.error
});
this.currentAdHocMetadata = {
taskName: result.log.task_name,
target: result.log.target || parsed.target || 'N/A',
duration: parsed.duration || 'N/A',
returnCode: parsed.returnCode,
date: result.log.date || '',
isSuccess: isSuccess,
error: parsed.error
};
this.currentAdHocTitle = `Log: ${result.log.task_name}`;
this.showModal(this.currentAdHocTitle, `
`);
return;
}
// Déterminer le statut global
const isSuccess = result.log.status === 'completed';
const statusConfig = {
completed: { icon: 'fa-check-circle', color: 'green', text: 'Succès' },
failed: { icon: 'fa-times-circle', color: 'red', text: 'Échoué' },
running: { icon: 'fa-spinner fa-spin', color: 'blue', text: 'En cours' },
pending: { icon: 'fa-clock', color: 'yellow', text: 'En attente' }
};
const status = statusConfig[result.log.status] || statusConfig.failed;
// Générer les onglets des hôtes
let hostTabsHtml = '';
if (hostOutputs.length > 1 || (hostOutputs.length === 1 && hostOutputs[0].hostname !== 'output')) {
hostTabsHtml = `
Sortie par hôte
(${totalHosts} hôtes: ${successCount} OK ${failedCount > 0 ? `, ${failedCount} échec ` : ''})
Voir tout
${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 `
${this.escapeHtml(host.hostname)}
`;
}).join('')}
`;
}
// Contenu du modal amélioré
const modalContent = `
Résultat d'exécution
${status.text} • Cible: ${this.escapeHtml(result.log.target || parsed.target || 'N/A')}
${parsed.duration || 'N/A'}
${parsed.returnCode !== undefined ? `
Code: ${parsed.returnCode}
` : ''}
${hostTabsHtml}
Sortie
Copier
${this.formatAnsibleOutput(hostOutputs.length > 0 ? hostOutputs[0].output : parsed.output, isSuccess)}
${parsed.error ? `
Erreurs
${this.escapeHtml(parsed.error)}
` : ''}
Fermer
`;
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, `
`);
// Remplir la sortie brute formatée
setTimeout(() => {
const rawEl = document.getElementById('ansible-raw-output');
if (rawEl) {
rawEl.innerHTML = this.formatAnsibleOutput(rawOutput, true);
}
}, 100);
}
returnToStructuredPlaybookView() {
this.showStructuredPlaybookViewModal();
}
renderAdHocStructuredView(hostOutputs, metadata) {
/**
* Génère une vue structurée pour les commandes ad-hoc (similaire aux playbooks)
*/
const isSuccess = metadata.isSuccess;
const totalHosts = hostOutputs.length;
const successCount = hostOutputs.filter(h => h.status === 'changed' || h.status === 'success').length;
const failedCount = hostOutputs.filter(h => h.status === 'failed' || h.status === 'unreachable').length;
const successRate = totalHosts > 0 ? Math.round((successCount / totalHosts) * 100) : 0;
// Générer les cartes d'hôtes
const hostCardsHtml = hostOutputs.map(host => {
const isFailed = host.status === 'failed' || host.status === 'unreachable';
const hasChanges = host.status === 'changed';
let statusClass, statusIcon;
if (isFailed) {
statusClass = 'border-red-500/50 bg-red-900/20';
statusIcon = ' ';
} else if (hasChanges) {
statusClass = 'border-yellow-500/50 bg-yellow-900/20';
statusIcon = ' ';
} else {
statusClass = 'border-green-500/50 bg-green-900/20';
statusIcon = ' ';
}
const hostStatus = isFailed ? 'failed' : (hasChanges ? 'changed' : 'ok');
return `
${statusIcon}
${this.escapeHtml(host.hostname)}
${host.status.toUpperCase()}
${this.escapeHtml(host.output.substring(0, 60))}${host.output.length > 60 ? '...' : ''}
`;
}).join('');
return `
Changed
${hostOutputs.filter(h => h.status === 'changed').length}
Success Rate
${successRate}%
État des Hôtes
Tous
OK
Changed
Failed
${hostCardsHtml}
${metadata.error ? `
Erreurs détectées
${this.escapeHtml(metadata.error)}
` : ''}
`;
}
showAdHocHostDetails(hostname) {
const hostOutputs = this.currentTaskLogHostOutputs || [];
const host = hostOutputs.find(h => h.hostname === hostname);
if (!host) {
this.showNotification('Hôte non trouvé', 'error');
return;
}
const isFailed = host.status === 'failed' || host.status === 'unreachable';
const hasChanges = host.status === 'changed';
let statusClass, statusIcon, statusText;
if (isFailed) {
statusClass = 'bg-red-900/30 border-red-700/50';
statusIcon = ' ';
statusText = 'FAILED ';
} else if (hasChanges) {
statusClass = 'bg-yellow-900/30 border-yellow-700/50';
statusIcon = ' ';
statusText = 'CHANGED ';
} else {
statusClass = 'bg-green-900/30 border-green-700/50';
statusIcon = ' ';
statusText = 'OK ';
}
const content = `
Retour au résumé
Résumé
›
${this.escapeHtml(hostname)}
${this.escapeHtml(hostname)}
Statut: ${statusText}
${statusIcon}
Sortie
Copier
${this.formatAnsibleOutput(host.output, !isFailed)}
Fermer
`;
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, `
`);
}
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 `
${statsHtml}
État des Hôtes
Tous
OK
Changed
Failed
${hostCardsHtml}
Hiérarchie des Tâches
Tout déplier
Tout replier
${taskTreeHtml}
Afficher la sortie brute
`;
}
renderHostStatusCards(parsedOutput) {
const hosts = Object.entries(parsedOutput.recap);
if (hosts.length === 0) {
return 'Aucun hôte détecté
';
}
return hosts.map(([hostname, stats]) => {
const total = stats.ok + stats.changed + stats.failed + stats.unreachable + stats.skipped;
const successPercent = total > 0 ? Math.round(((stats.ok + stats.changed) / total) * 100) : 0;
const isFailed = stats.failed > 0 || stats.unreachable > 0;
const hasChanges = stats.changed > 0;
let statusClass, statusIcon, statusBg;
if (isFailed) {
statusClass = 'border-red-500/50 bg-red-900/20';
statusIcon = ' ';
statusBg = 'bg-red-500';
} else if (hasChanges) {
statusClass = 'border-yellow-500/50 bg-yellow-900/20';
statusIcon = ' ';
statusBg = 'bg-yellow-500';
} else {
statusClass = 'border-green-500/50 bg-green-900/20';
statusIcon = ' ';
statusBg = 'bg-green-500';
}
const hostStatus = isFailed ? 'failed' : (hasChanges ? 'changed' : 'ok');
return `
${statusIcon}
${this.escapeHtml(hostname)}
${successPercent}%
${stats.ok > 0 ? `
` : ''}
${stats.changed > 0 ? `
` : ''}
${stats.skipped > 0 ? `
` : ''}
${stats.failed > 0 ? `
` : ''}
${stats.unreachable > 0 ? `
` : ''}
${stats.ok} ok
${stats.changed} chg
${stats.failed} fail
`;
}).join('');
}
renderTaskHierarchy(parsedOutput) {
if (parsedOutput.plays.length === 0) {
return 'Aucune tâche détectée
';
}
return parsedOutput.plays.map((play, playIndex) => {
const playTasks = play.tasks;
const allTasksSuccess = playTasks.every(t =>
t.hostResults.every(r => r.status === 'ok' || r.status === 'changed' || r.status === 'skipping')
);
const hasFailedTasks = playTasks.some(t =>
t.hostResults.some(r => r.status === 'failed' || r.status === 'fatal' || r.status === 'unreachable')
);
const playStatusIcon = hasFailedTasks
? ' '
: ' ';
const tasksHtml = playTasks.map((task, taskIndex) => {
const hasFailures = task.hostResults.some(r => r.status === 'failed' || r.status === 'fatal' || r.status === 'unreachable');
const hasChanges = task.hostResults.some(r => r.status === 'changed');
const allSkipped = task.hostResults.every(r => r.status === 'skipping' || r.status === 'skipped');
let taskIcon, taskColor;
if (hasFailures) {
taskIcon = 'fa-times-circle';
taskColor = 'text-red-400';
} else if (hasChanges) {
taskIcon = 'fa-exchange-alt';
taskColor = 'text-yellow-400';
} else if (allSkipped) {
taskIcon = 'fa-forward';
taskColor = 'text-gray-500';
} else {
taskIcon = 'fa-check-circle';
taskColor = 'text-green-400';
}
const hostResultsHtml = task.hostResults.map(result => {
let resultIcon, resultColor, resultBg;
switch(result.status) {
case 'ok':
resultIcon = 'fa-check'; resultColor = 'text-green-400'; resultBg = 'bg-green-900/30';
break;
case 'changed':
resultIcon = 'fa-exchange-alt'; resultColor = 'text-yellow-400'; resultBg = 'bg-yellow-900/30';
break;
case 'failed':
case 'fatal':
resultIcon = 'fa-times'; resultColor = 'text-red-400'; resultBg = 'bg-red-900/30';
break;
case 'unreachable':
resultIcon = 'fa-unlink'; resultColor = 'text-orange-400'; resultBg = 'bg-orange-900/30';
break;
case 'skipping':
case 'skipped':
resultIcon = 'fa-forward'; resultColor = 'text-gray-500'; resultBg = 'bg-gray-800/50';
break;
default:
resultIcon = 'fa-question'; resultColor = 'text-gray-400'; resultBg = 'bg-gray-800/50';
}
// Extraire les données importantes de l'output
let outputPreview = '';
if (result.parsedOutput) {
const po = result.parsedOutput;
if (po.msg) outputPreview = po.msg;
else if (po.stdout) outputPreview = po.stdout.substring(0, 100);
else if (po.cmd) outputPreview = Array.isArray(po.cmd) ? po.cmd.join(' ') : po.cmd;
}
return `
${this.escapeHtml(result.hostname)}
${outputPreview ? `${this.escapeHtml(outputPreview.substring(0, 50))}${outputPreview.length > 50 ? '...' : ''} ` : ''}
${result.status}
`;
}).join('');
return `
${this.escapeHtml(task.name)}
${task.hostResults.length} hôte(s)
${task.hostResults.slice(0, 5).map(r => {
const dotColor = r.status === 'ok' ? 'bg-green-500' :
r.status === 'changed' ? 'bg-yellow-500' :
r.status === 'failed' || r.status === 'fatal' ? 'bg-red-500' : 'bg-gray-500';
return `
`;
}).join('')}
${task.hostResults.length > 5 ? `
+${task.hostResults.length - 5} ` : ''}
${hostResultsHtml}
`;
}).join('');
return `
`;
}).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 `
Success Rate
${successRate}%
`;
}
// Méthodes d'interaction pour la vue structurée
filterAnsibleViewByStatus(status) {
document.querySelectorAll('.av-filter-btn').forEach(btn => {
btn.classList.remove('active', 'bg-gray-700', 'text-gray-300');
btn.classList.add('bg-gray-700/50', 'text-gray-400');
});
document.querySelector(`.av-filter-btn[data-filter="${status}"]`)?.classList.add('active', 'bg-gray-700', 'text-gray-300');
document.querySelector(`.av-filter-btn[data-filter="${status}"]`)?.classList.remove('bg-gray-700/50', 'text-gray-400');
document.querySelectorAll('.host-card-item').forEach(card => {
if (status === 'all' || card.dataset.status === status) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
}
expandAllTasks() {
document.querySelectorAll('.task-item').forEach(item => {
item.setAttribute('open', 'open');
});
}
collapseAllTasks() {
document.querySelectorAll('.task-item').forEach(item => {
item.removeAttribute('open');
});
}
showHostDetails(hostname) {
if (!this.currentParsedOutput || !this.currentParsedOutput.hosts[hostname]) return;
const hostData = this.currentParsedOutput.hosts[hostname];
const recapData = this.currentParsedOutput.recap[hostname] || {};
const tasksHtml = hostData.taskResults.map(result => {
let statusIcon, statusColor;
switch(result.status) {
case 'ok': statusIcon = 'fa-check'; statusColor = 'text-green-400'; break;
case 'changed': statusIcon = 'fa-exchange-alt'; statusColor = 'text-yellow-400'; break;
case 'failed': case 'fatal': statusIcon = 'fa-times'; statusColor = 'text-red-400'; break;
default: statusIcon = 'fa-minus'; statusColor = 'text-gray-400';
}
return `
${this.escapeHtml(result.taskName)}
${result.status.toUpperCase()}
${result.output ? `
${this.escapeHtml(result.output)}
` : ''}
`;
}).join('');
const content = `
Retour au résumé
Résumé
›
${this.escapeHtml(hostname)}
${this.escapeHtml(hostname)}
${recapData.ok || 0} ok •
${recapData.changed || 0} changed •
${recapData.failed || 0} failed
${tasksHtml}
Fermer
`;
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 = `
Chargement...
`;
} else if (container) {
// Garder le header mais montrer le chargement dans la liste
const existingHeader = container.querySelector('.flex.flex-col.gap-2.mb-4');
if (!existingHeader) {
container.innerHTML = `
Chargement...
`;
}
}
const params = new URLSearchParams();
if (this.currentStatusFilter && this.currentStatusFilter !== 'all') {
params.append('status', this.currentStatusFilter);
}
// Si plusieurs dates sont sélectionnées, utiliser le premier jour comme filtre principal (compat API)
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
const firstDate = this.parseDateKey(this.selectedTaskDates[0]);
const year = String(firstDate.getFullYear());
const month = String(firstDate.getMonth() + 1).padStart(2, '0');
const day = String(firstDate.getDate()).padStart(2, '0');
params.append('year', year);
params.append('month', month);
params.append('day', day);
} else {
if (this.currentDateFilter.year) params.append('year', this.currentDateFilter.year);
if (this.currentDateFilter.month) params.append('month', this.currentDateFilter.month);
if (this.currentDateFilter.day) params.append('day', this.currentDateFilter.day);
}
// Filtres d'heure
if (this.currentHourStart) {
params.append('hour_start', this.currentHourStart);
}
if (this.currentHourEnd) {
params.append('hour_end', this.currentHourEnd);
}
if (this.currentTargetFilter && this.currentTargetFilter !== 'all') {
params.append('target', this.currentTargetFilter);
}
if (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') {
params.append('category', this.currentCategoryFilter);
}
// Filtre par type de source
if (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') {
params.append('source_type', this.currentSourceTypeFilter);
}
// Pagination côté serveur
params.append('limit', this.tasksPerPage);
params.append('offset', 0); // Toujours commencer à 0 lors d'un nouveau filtre
try {
const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`);
this.taskLogs = result.logs || [];
this.tasksTotalCount = result.total_count || 0;
this.tasksHasMore = result.has_more || false;
this.tasksDisplayedCount = this.taskLogs.length;
this.renderTasks();
this.updateTaskCounts();
} catch (error) {
console.error('Erreur chargement logs:', error);
}
}
updateTaskCounts() {
// Mettre à jour les compteurs dans les boutons
const stats = this.taskLogsStats || { total: 0, completed: 0, failed: 0, running: 0, pending: 0 };
const running = this.tasks.filter(t => t.status === 'running').length;
const countAll = document.getElementById('count-all');
const countRunning = document.getElementById('count-running');
const countCompleted = document.getElementById('count-completed');
const countFailed = document.getElementById('count-failed');
if (countAll) countAll.textContent = (stats.total || 0) + running;
if (countRunning) countRunning.textContent = running + (stats.running || 0);
if (countCompleted) countCompleted.textContent = stats.completed || 0;
if (countFailed) countFailed.textContent = stats.failed || 0;
// Mettre à jour le badge principal
const badge = document.getElementById('tasks-count-badge');
if (badge) badge.textContent = (stats.total || 0) + running;
console.log('Task counts updated:', { stats, running });
}
updateDateFilters() {
// Mettre à jour le libellé du bouton et le résumé sous le calendrier
const label = document.getElementById('task-date-filter-label');
const summary = document.getElementById('task-cal-summary');
let text = 'Toutes les dates';
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
if (this.selectedTaskDates.length === 1) {
const d = this.parseDateKey(this.selectedTaskDates[0]);
text = d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: '2-digit' });
} else {
text = `${this.selectedTaskDates.length} jours sélectionnés`;
}
}
if (label) label.textContent = text;
if (summary) summary.textContent = text;
}
applyDateFilter() {
// Lorsque plusieurs dates sont sélectionnées, on garde uniquement la première pour l'API
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
const firstDate = this.parseDateKey(this.selectedTaskDates[0]);
this.currentDateFilter.year = String(firstDate.getFullYear());
this.currentDateFilter.month = String(firstDate.getMonth() + 1).padStart(2, '0');
this.currentDateFilter.day = String(firstDate.getDate()).padStart(2, '0');
} else {
this.currentDateFilter = { year: '', month: '', day: '' };
}
// Récupérer les heures depuis les inputs
const hourStartInput = document.getElementById('task-cal-hour-start');
const hourEndInput = document.getElementById('task-cal-hour-end');
this.currentHourStart = hourStartInput ? hourStartInput.value : '';
this.currentHourEnd = hourEndInput ? hourEndInput.value : '';
this.updateDateFilters();
this.loadTaskLogsWithFilters();
}
clearDateFilters() {
this.currentDateFilter = { year: '', month: '', day: '' };
this.selectedTaskDates = [];
this.currentHourStart = '';
this.currentHourEnd = '';
// Réinitialiser les inputs d'heure
const hourStartInput = document.getElementById('task-cal-hour-start');
const hourEndInput = document.getElementById('task-cal-hour-end');
if (hourStartInput) hourStartInput.value = '';
if (hourEndInput) hourEndInput.value = '';
this.updateDateFilters();
this.renderTaskCalendar();
this.loadTaskLogsWithFilters();
}
async refreshTaskLogs() {
// Ne pas utiliser showLoading() pour éviter le message "Exécution de la tâche..."
// Afficher un indicateur de chargement inline à la place
const container = document.getElementById('tasks-list');
if (container) {
container.innerHTML = `
Chargement des logs...
`;
}
try {
const [taskLogsData, taskStatsData, taskDatesData] = await Promise.all([
this.apiCall(`/api/tasks/logs?limit=${this.tasksPerPage}&offset=0`),
this.apiCall('/api/tasks/logs/stats'),
this.apiCall('/api/tasks/logs/dates')
]);
this.taskLogs = taskLogsData.logs || [];
this.tasksTotalCount = taskLogsData.total_count || 0;
this.tasksHasMore = taskLogsData.has_more || false;
this.taskLogsStats = taskStatsData;
this.taskLogsDates = taskDatesData;
this.renderTasks();
this.updateDateFilters();
this.updateTaskCounts();
this.showNotification('Logs de tâches rafraîchis', 'success');
} catch (error) {
this.showNotification(`Erreur: ${error.message}`, 'error');
if (container) {
container.innerHTML = `
`;
}
}
}
createTaskCard(task, isRunning) {
const statusBadge = this.getStatusBadge(task.status);
const progressBar = isRunning ? `
` : '';
const startTime = task.start_time
? new Date(task.start_time).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: '--';
const duration = task.duration || '--';
// Icône selon le statut
const statusIcon = {
'completed': ' ',
'failed': ' ',
'running': ' ',
'pending': ' '
}[task.status] || ' ';
const taskCard = document.createElement('div');
taskCard.className = `host-card ${isRunning ? 'border-l-4 border-blue-500' : ''} ${task.status === 'failed' ? 'border-l-4 border-red-500' : ''}`;
taskCard.innerHTML = `
${statusIcon}
${task.name}
${statusBadge}
Cible: ${task.host}
Début: ${startTime} • Durée: ${duration}
${progressBar}
${task.output ? `
${this.escapeHtml(task.output.substring(0, 150))}${task.output.length > 150 ? '...' : ''}
` : ''}
${task.error ? `
${this.escapeHtml(task.error.substring(0, 150))}${task.error.length > 150 ? '...' : ''}
` : ''}
${task.status === 'failed' ? `
` : ''}
`;
return taskCard;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
viewTaskDetails(taskId) {
const task = this.tasks.find(t => t.id === taskId);
if (!task) {
this.showNotification('Tâche non trouvée', 'error');
return;
}
const statusBadge = this.getStatusBadge(task.status);
const startTime = task.start_time
? new Date(task.start_time).toLocaleString('fr-FR')
: '--';
this.showModal(`Détails de la tâche #${task.id}`, `
${task.name}
${statusBadge}
Cible:
${task.host}
Durée:
${task.duration || '--'}
Début:
${startTime}
Progression:
${task.progress}%
${task.output ? `
Sortie
Copier
${this.escapeHtml(task.output)}
` : ''}
${task.error ? `
Erreur
${this.escapeHtml(task.error)}
` : ''}
${task.status === 'failed' ? `
Réessayer
` : ''}
Fermer
`);
}
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 =>
`${h.name} (${h.ip}) `
).join('');
const groupOptions = this.ansibleGroups.map(g =>
`${g} `
).join('');
// Catégories par défaut si aucune n'est chargée
const categories = this.adhocCategories.length > 0 ? this.adhocCategories : [
{ name: 'default', description: 'Commandes générales', color: '#7c3aed', icon: 'fa-terminal' },
{ name: 'diagnostic', description: 'Diagnostic', color: '#10b981', icon: 'fa-stethoscope' },
{ name: 'maintenance', description: 'Maintenance', color: '#f59e0b', icon: 'fa-wrench' },
{ name: 'deployment', description: 'Déploiement', color: '#3b82f6', icon: 'fa-rocket' }
];
const categoryOptions = categories.map(c =>
`${c.name} `
).join('');
// Stocker le filtre actuel (utiliser la propriété de classe)
this.currentHistoryCategoryFilter = this.currentHistoryCategoryFilter || 'all';
// Générer la liste de l'historique groupé par catégorie
const historyByCategory = {};
(this.adhocHistory || []).forEach(cmd => {
const cat = cmd.category || 'default';
if (!historyByCategory[cat]) historyByCategory[cat] = [];
historyByCategory[cat].push(cmd);
});
let historyHtml = '';
if (Object.keys(historyByCategory).length > 0) {
Object.entries(historyByCategory).forEach(([category, commands]) => {
// Filtrer par catégorie si un filtre est actif
if (this.currentHistoryCategoryFilter !== 'all' && category !== this.currentHistoryCategoryFilter) {
return; // Skip cette catégorie
}
const catInfo = categories.find(c => c.name === category) || { color: '#7c3aed', icon: 'fa-folder' };
historyHtml += `
${category.toUpperCase()}
(${commands.length})
${commands.map(cmd => `
${this.escapeHtml(cmd.command)}
${cmd.target}
${cmd.use_count || 1}x
`).join('')}
`;
});
}
// Afficher les catégories disponibles avec filtrage et actions
// Ajouter "toutes" comme option de filtrage
let categoriesListHtml = `
Toutes
`;
categories.forEach(cat => {
const isDefault = cat.name === 'default';
categoriesListHtml += `
${cat.name}
${!isDefault ? `
` : ` `}
`;
});
this.showModal('Console Ad-Hoc Ansible', `
Historique
Catégorie
Catégories:
${categoriesListHtml}
${historyHtml || '
Aucune commande dans l\'historiqueExécutez une commande pour la sauvegarder
'}
`);
// Initialiser l'aperçu des hôtes ciblés
this.updateTargetHostsPreview('all');
// Ajouter l'event listener pour mettre à jour l'aperçu quand la cible change
const targetSelect = document.getElementById('adhoc-target');
if (targetSelect) {
targetSelect.addEventListener('change', (e) => {
this.updateTargetHostsPreview(e.target.value);
});
}
}
/**
* Récupère la liste des hôtes pour une cible donnée (groupe, hôte individuel ou "all")
*/
getHostsForTarget(target) {
if (!target || target === 'all') {
// Tous les hôtes
return this.hosts;
}
// Vérifier si c'est un groupe
if (this.ansibleGroups.includes(target)) {
return this.hosts.filter(h => h.groups && h.groups.includes(target));
}
// Sinon c'est un hôte individuel
const host = this.hosts.find(h => h.name === target);
return host ? [host] : [];
}
/**
* Met à jour l'aperçu des hôtes ciblés dans la console Ad-Hoc
*/
updateTargetHostsPreview(target) {
const listContainer = document.getElementById('adhoc-target-hosts-list');
const countSpan = document.getElementById('adhoc-target-hosts-count');
const previewContainer = document.getElementById('adhoc-target-hosts-preview');
if (!listContainer || !countSpan || !previewContainer) return;
const hosts = this.getHostsForTarget(target);
// Mettre à jour le compteur
countSpan.textContent = `${hosts.length} hôte${hosts.length > 1 ? 's' : ''}`;
// Générer les badges des hôtes
if (hosts.length === 0) {
listContainer.innerHTML = 'Aucun hôte trouvé ';
previewContainer.classList.add('border-amber-700/50');
previewContainer.classList.remove('border-gray-700');
} else {
previewContainer.classList.remove('border-amber-700/50');
previewContainer.classList.add('border-gray-700');
listContainer.innerHTML = hosts.map(h => {
const statusColor = h.bootstrap_ok ? 'bg-green-900/40 text-green-400 border-green-700/50' : 'bg-gray-700/50 text-gray-400 border-gray-600/50';
const statusIcon = h.bootstrap_ok ? 'fa-check-circle' : 'fa-circle';
return `
${this.escapeHtml(h.name)}
${h.ip ? `${h.ip} ` : ''}
`;
}).join('');
}
}
loadHistoryCommand(command, target, module, become) {
document.getElementById('adhoc-command').value = command;
document.getElementById('adhoc-target').value = target;
document.getElementById('adhoc-module').value = module;
document.getElementById('adhoc-become').checked = become;
// Mettre à jour l'aperçu des hôtes ciblés
this.updateTargetHostsPreview(target);
this.showNotification('Commande chargée depuis l\'historique', 'info');
}
/**
* Rafraîchit dynamiquement la section historique des commandes Ad-Hoc
* sans recharger toute la modale
*/
async refreshAdHocHistory() {
try {
// Récupérer l'historique mis à jour depuis l'API
const historyData = await this.apiCall('/api/adhoc/history');
this.adhocHistory = historyData.commands || [];
const historyContainer = document.getElementById('adhoc-history-container');
if (!historyContainer) return;
// Catégories pour le rendu
const categories = this.adhocCategories.length > 0 ? this.adhocCategories : [
{ name: 'default', description: 'Commandes générales', color: '#7c3aed', icon: 'fa-terminal' },
{ name: 'diagnostic', description: 'Diagnostic', color: '#10b981', icon: 'fa-stethoscope' },
{ name: 'maintenance', description: 'Maintenance', color: '#f59e0b', icon: 'fa-wrench' },
{ name: 'deployment', description: 'Déploiement', color: '#3b82f6', icon: 'fa-rocket' }
];
// Générer la liste de l'historique groupé par catégorie
const historyByCategory = {};
(this.adhocHistory || []).forEach(cmd => {
const cat = cmd.category || 'default';
if (!historyByCategory[cat]) historyByCategory[cat] = [];
historyByCategory[cat].push(cmd);
});
let historyHtml = '';
if (Object.keys(historyByCategory).length > 0) {
Object.entries(historyByCategory).forEach(([category, commands]) => {
// Filtrer par catégorie si un filtre est actif
if (this.currentHistoryCategoryFilter !== 'all' && category !== this.currentHistoryCategoryFilter) {
return;
}
const catInfo = categories.find(c => c.name === category) || { color: '#7c3aed', icon: 'fa-folder' };
historyHtml += `
${category.toUpperCase()}
(${commands.length})
${commands.map(cmd => `
${this.escapeHtml(cmd.command)}
${cmd.target}
${cmd.use_count || 1}x
`).join('')}
`;
});
}
// Mettre à jour le contenu avec animation
historyContainer.style.opacity = '0.5';
historyContainer.innerHTML = historyHtml || ' Aucune commande dans l\'historiqueExécutez une commande pour la sauvegarder
';
// Animation de mise à jour
setTimeout(() => {
historyContainer.style.opacity = '1';
historyContainer.style.transition = 'opacity 0.3s ease';
}, 50);
} catch (error) {
console.error('Erreur lors du rafraîchissement de l\'historique:', error);
}
}
async deleteHistoryCommand(commandId) {
if (!confirm('Supprimer cette commande de l\'historique ?')) return;
try {
await this.apiCall(`/api/adhoc/history/${commandId}`, { method: 'DELETE' });
this.showNotification('Commande supprimée', 'success');
// Recharger l'historique et réafficher la console
const historyData = await this.apiCall('/api/adhoc/history');
this.adhocHistory = historyData.commands || [];
this.showAdHocConsole();
} catch (error) {
this.showNotification(`Erreur: ${error.message}`, 'error');
}
}
async editHistoryCommand(commandId) {
const categoryOptions = this.adhocCategories.map(c =>
`${c.name} `
).join('');
this.showModal('Modifier la catégorie', `
`);
}
async updateCommandCategory(event, commandId) {
event.preventDefault();
const formData = new FormData(event.target);
try {
await this.apiCall(`/api/adhoc/history/${commandId}/category?category=${encodeURIComponent(formData.get('category'))}&description=${encodeURIComponent(formData.get('description') || '')}`, {
method: 'PUT'
});
this.showNotification('Catégorie mise à jour', 'success');
const historyData = await this.apiCall('/api/adhoc/history');
this.adhocHistory = historyData.commands || [];
this.showAdHocConsole();
} catch (error) {
this.showNotification(`Erreur: ${error.message}`, 'error');
}
}
showAddCategoryModal() {
this.showModal('Ajouter une catégorie', `
`);
}
async createCategory(event) {
event.preventDefault();
const formData = new FormData(event.target);
try {
await this.apiCall(`/api/adhoc/categories?name=${encodeURIComponent(formData.get('name'))}&description=${encodeURIComponent(formData.get('description') || '')}&color=${encodeURIComponent(formData.get('color'))}&icon=${encodeURIComponent(formData.get('icon'))}`, {
method: 'POST'
});
this.showNotification('Catégorie créée', 'success');
const categoriesData = await this.apiCall('/api/adhoc/categories');
this.adhocCategories = categoriesData.categories || [];
this.showAdHocConsole();
} catch (error) {
this.showNotification(`Erreur: ${error.message}`, 'error');
}
}
filterHistoryByCategory(category) {
this.currentHistoryCategoryFilter = category;
// Mettre à jour les boutons de filtre visuellement
document.querySelectorAll('.category-filter-btn').forEach(btn => {
const btnCategory = btn.getAttribute('data-category-filter');
if (btnCategory === category) {
if (category === 'all') {
btn.className = 'category-filter-btn active inline-flex items-center px-2 py-1 rounded text-xs transition-all bg-purple-600 text-white hover:bg-purple-500';
} else {
btn.classList.add('active', 'ring-2', 'ring-white/50');
}
} else {
btn.classList.remove('active', 'ring-2', 'ring-white/50');
if (btnCategory === 'all') {
btn.className = 'category-filter-btn inline-flex items-center px-2 py-1 rounded text-xs transition-all bg-gray-700 text-gray-400 hover:bg-gray-600';
}
}
});
// Filtrer les sections de l'historique
document.querySelectorAll('.history-category-section').forEach(section => {
const sectionCategory = section.getAttribute('data-category');
if (category === 'all' || sectionCategory === category) {
section.classList.remove('hidden');
} else {
section.classList.add('hidden');
}
});
// Si aucun résultat visible, afficher un message
const visibleSections = document.querySelectorAll('.history-category-section:not(.hidden)');
const historyContainer = document.querySelector('.overflow-y-auto[style*="max-height: 400px"]');
if (historyContainer && visibleSections.length === 0) {
// Pas de commandes dans cette catégorie
const emptyMsg = historyContainer.querySelector('.empty-filter-msg');
if (!emptyMsg) {
const msg = document.createElement('p');
msg.className = 'empty-filter-msg text-xs text-gray-500 text-center py-4';
msg.innerHTML = ' Aucune commande dans cette catégorie';
historyContainer.appendChild(msg);
}
} else {
const emptyMsg = historyContainer?.querySelector('.empty-filter-msg');
if (emptyMsg) emptyMsg.remove();
}
}
editCategory(categoryName) {
const category = this.adhocCategories.find(c => c.name === categoryName);
if (!category) {
this.showNotification('Catégorie non trouvée', 'error');
return;
}
this.showModal(`Modifier la catégorie: ${categoryName}`, `
`);
}
async updateCategory(event, originalName) {
event.preventDefault();
const formData = new FormData(event.target);
const newName = formData.get('name') || originalName;
try {
await this.apiCall(`/api/adhoc/categories/${encodeURIComponent(originalName)}`, {
method: 'PUT',
body: JSON.stringify({
name: newName,
description: formData.get('description') || '',
color: formData.get('color'),
icon: formData.get('icon')
})
});
this.showNotification('Catégorie mise à jour', 'success');
const categoriesData = await this.apiCall('/api/adhoc/categories');
this.adhocCategories = categoriesData.categories || [];
this.showAdHocConsole();
} catch (error) {
this.showNotification(`Erreur: ${error.message}`, 'error');
}
}
async deleteCategory(categoryName) {
if (categoryName === 'default') {
this.showNotification('La catégorie par défaut ne peut pas être supprimée', 'error');
return;
}
// Compter les commandes dans cette catégorie
const commandsInCategory = (this.adhocHistory || []).filter(cmd => cmd.category === categoryName).length;
const confirmMsg = commandsInCategory > 0
? `Supprimer la catégorie "${categoryName}" ?\n\n${commandsInCategory} commande(s) seront déplacées vers "default".`
: `Supprimer la catégorie "${categoryName}" ?`;
if (!confirm(confirmMsg)) return;
try {
await this.apiCall(`/api/adhoc/categories/${encodeURIComponent(categoryName)}`, {
method: 'DELETE'
});
this.showNotification('Catégorie supprimée', 'success');
// Recharger les données
const [categoriesData, historyData] = await Promise.all([
this.apiCall('/api/adhoc/categories'),
this.apiCall('/api/adhoc/history')
]);
this.adhocCategories = categoriesData.categories || [];
this.adhocHistory = historyData.commands || [];
// Réinitialiser le filtre si on filtrait sur cette catégorie
if (this.currentHistoryCategoryFilter === categoryName) {
this.currentHistoryCategoryFilter = 'all';
}
this.showAdHocConsole();
} catch (error) {
this.showNotification(`Erreur: ${error.message}`, 'error');
}
}
async executeAdHocCommand(event) {
event.preventDefault();
const formData = new FormData(event.target);
const payload = {
target: formData.get('target'),
command: formData.get('command'),
module: formData.get('module'),
become: formData.get('become') === 'on',
timeout: parseInt(formData.get('timeout')) || 60,
// catégorie d'historique choisie dans le select
category: formData.get('save_category') || 'default'
};
const resultDiv = document.getElementById('adhoc-result');
const stdoutPre = document.getElementById('adhoc-stdout');
const stderrPre = document.getElementById('adhoc-stderr');
const stderrSection = document.getElementById('adhoc-stderr-section');
const statusIcon = document.getElementById('adhoc-status-icon');
const resultMeta = document.getElementById('adhoc-result-meta');
const resultStats = document.getElementById('adhoc-result-stats');
const resultHeader = document.getElementById('adhoc-result-header');
// Reset et afficher
resultDiv.classList.remove('hidden');
stderrSection.classList.add('hidden');
resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-gray-800/80 border-b border-gray-700';
statusIcon.innerHTML = ' ';
statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-blue-900/50';
resultMeta.textContent = 'Exécution en cours...';
resultStats.innerHTML = '';
stdoutPre.innerHTML = '⏳ Exécution de la commande... ';
try {
const result = await this.apiCall('/api/ansible/adhoc', {
method: 'POST',
body: JSON.stringify(payload)
});
// Mise à jour du header avec le statut
if (result.success) {
resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-green-900/30 border-b border-green-800/50';
statusIcon.innerHTML = ' ';
statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-green-900/50';
resultMeta.innerHTML = `Succès • Cible: ${this.escapeHtml(result.target)}`;
} else {
resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-red-900/30 border-b border-red-800/50';
statusIcon.innerHTML = ' ';
statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-red-900/50';
resultMeta.innerHTML = `Échec • Cible: ${this.escapeHtml(result.target)}`;
}
// Stats dans le header
resultStats.innerHTML = `
${result.duration}s
Code: ${result.return_code}
`;
// Parser et afficher le résultat avec onglets par hôte
const stdoutContent = result.stdout || '(pas de sortie)';
const hostOutputs = this.parseOutputByHost(stdoutContent);
// Si plusieurs hôtes, afficher avec onglets
if (hostOutputs.length > 1) {
this.renderHostTabs(hostOutputs, result.success);
} else {
// Un seul hôte ou output non parsable
stdoutPre.innerHTML = this.formatAnsibleOutput(stdoutContent, result.success);
}
// Afficher STDERR si présent
if (result.stderr && result.stderr.trim()) {
stderrSection.classList.remove('hidden');
stderrPre.innerHTML = this.formatAnsibleWarnings(result.stderr);
}
this.showNotification(
result.success ? 'Commande exécutée avec succès' : 'Commande échouée',
result.success ? 'success' : 'error'
);
// Mettre à jour dynamiquement l'historique des commandes
await this.refreshAdHocHistory();
} catch (error) {
resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-red-900/30 border-b border-red-800/50';
statusIcon.innerHTML = ' ';
statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-red-900/50';
resultMeta.innerHTML = 'Erreur de connexion ';
resultStats.innerHTML = '';
stdoutPre.innerHTML = `❌ ${this.escapeHtml(error.message)} `;
this.showNotification('Erreur lors de l\'exécution', 'error');
}
}
formatAnsibleOutput(output, isSuccess) {
// Formater la sortie Ansible pour une meilleure lisibilité
let formatted = this.escapeHtml(output);
// Colorer les hosts avec statut
formatted = formatted.replace(/^(\S+)\s*\|\s*(CHANGED|SUCCESS)\s*=>/gm,
'$1 $2 =>');
formatted = formatted.replace(/^(\S+)\s*\|\s*(FAILED|UNREACHABLE)\s*(!)?\s*=>/gm,
'$1 $2 =>');
// Colorer les clés JSON
formatted = formatted.replace(/"(\w+)"\s*:/g, '"$1" :');
// Colorer les valeurs importantes
formatted = formatted.replace(/: "([^"]+)"/g, ': "$1" ');
formatted = formatted.replace(/: (true|false)/g, ': $1 ');
formatted = formatted.replace(/: (\d+)/g, ': $1 ');
// Mettre en évidence les lignes de résumé
formatted = formatted.replace(/^(PLAY RECAP \*+)$/gm, '$1 ');
formatted = formatted.replace(/(ok=\d+)/g, '$1 ');
formatted = formatted.replace(/(changed=\d+)/g, '$1 ');
formatted = formatted.replace(/(unreachable=\d+)/g, '$1 ');
formatted = formatted.replace(/(failed=\d+)/g, '$1 ');
return formatted;
}
formatAnsibleWarnings(stderr) {
let formatted = this.escapeHtml(stderr);
// Mettre en évidence les warnings
formatted = formatted.replace(/\[WARNING\]:/g, '[WARNING]: ');
formatted = formatted.replace(/\[DEPRECATION WARNING\]:/g, '[DEPRECATION WARNING]: ');
// Colorer les URLs
formatted = formatted.replace(/(https?:\/\/[^\s<]+)/g, '$1 ');
// Mettre en évidence les chemins de fichiers
formatted = formatted.replace(/(\/[\w\-\.\/]+)/g, '$1 ');
return formatted;
}
parseOutputByHost(output) {
// Parser la sortie Ansible pour séparer par hôte
// Format typique: "hostname | STATUS | rc=X >>"
const hostOutputs = [];
const lines = output.split('\n');
let currentHost = null;
let currentOutput = [];
let currentStatus = 'unknown';
const hostPattern = /^(\S+)\s*\|\s*(CHANGED|SUCCESS|FAILED|UNREACHABLE)\s*\|?\s*rc=(\d+)\s*>>?/;
for (const line of lines) {
const match = line.match(hostPattern);
if (match) {
// Sauvegarder l'hôte précédent si existant
if (currentHost) {
hostOutputs.push({
hostname: currentHost,
status: currentStatus,
output: currentOutput.join('\n').trim()
});
}
// Commencer un nouvel hôte
currentHost = match[1];
currentStatus = match[2].toLowerCase();
currentOutput = [line];
} else if (currentHost) {
currentOutput.push(line);
} else {
// Lignes avant le premier hôte (header, etc.)
if (!hostOutputs.length && line.trim()) {
currentOutput.push(line);
}
}
}
// Ajouter le dernier hôte
if (currentHost) {
hostOutputs.push({
hostname: currentHost,
status: currentStatus,
output: currentOutput.join('\n').trim()
});
}
// Si aucun hôte trouvé, retourner l'output brut
if (hostOutputs.length === 0) {
return [{
hostname: 'output',
status: 'unknown',
output: output
}];
}
return hostOutputs;
}
renderHostTabs(hostOutputs, isSuccess) {
const stdoutSection = document.getElementById('adhoc-stdout-section');
if (!stdoutSection) return;
// Stocker les outputs pour référence
this.currentHostOutputs = hostOutputs;
// Générer les onglets
const tabsHtml = hostOutputs.map((host, index) => {
const statusColor = host.status === 'changed' || host.status === 'success'
? 'bg-green-600 hover:bg-green-500'
: host.status === 'failed' || host.status === 'unreachable'
? 'bg-red-600 hover:bg-red-500'
: 'bg-gray-600 hover:bg-gray-500';
const statusIcon = host.status === 'changed' || host.status === 'success'
? 'fa-check'
: host.status === 'failed' || host.status === 'unreachable'
? 'fa-times'
: 'fa-question';
return `
${this.escapeHtml(host.hostname)}
`;
}).join('');
// Générer le compteur d'hôtes
const successCount = hostOutputs.filter(h => h.status === 'changed' || h.status === 'success').length;
const failedCount = hostOutputs.filter(h => h.status === 'failed' || h.status === 'unreachable').length;
stdoutSection.innerHTML = `
Sortie par hôte
(${hostOutputs.length} hôtes:
${successCount} OK
${failedCount > 0 ? `, ${failedCount} échec ` : ''})
Voir tout
${tabsHtml}
${this.formatAnsibleOutput(hostOutputs[0].output, isSuccess)}
`;
}
switchHostTab(index) {
if (!this.currentHostOutputs || !this.currentHostOutputs[index]) return;
const hostOutput = this.currentHostOutputs[index];
const stdoutPre = document.getElementById('adhoc-stdout');
const tabs = document.querySelectorAll('.host-tab');
// Mettre à jour l'onglet actif
tabs.forEach((tab, i) => {
if (i === index) {
const host = this.currentHostOutputs[i];
const statusColor = host.status === 'changed' || host.status === 'success'
? 'bg-green-600'
: host.status === 'failed' || host.status === 'unreachable'
? 'bg-red-600'
: 'bg-gray-600';
tab.className = `host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all ${statusColor} text-white`;
} else {
tab.className = 'host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all bg-gray-700/50 text-gray-400 hover:text-white';
}
});
// Afficher le contenu
if (stdoutPre) {
stdoutPre.innerHTML = this.formatAnsibleOutput(hostOutput.output, hostOutput.status === 'changed' || hostOutput.status === 'success');
}
}
showAllHostsOutput() {
if (!this.currentHostOutputs) return;
const stdoutPre = document.getElementById('adhoc-stdout');
if (stdoutPre) {
const allOutput = this.currentHostOutputs.map(h => h.output).join('\n\n');
stdoutPre.innerHTML = this.formatAnsibleOutput(allOutput, true);
}
// Désélectionner tous les onglets
document.querySelectorAll('.host-tab').forEach(tab => {
tab.className = 'host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all bg-gray-700/50 text-gray-400 hover:text-white';
});
}
renderLogs() {
const container = document.getElementById('logs-container');
if (!container) return;
container.innerHTML = '';
if (this.logs.length === 0) {
container.innerHTML = `
`;
return;
}
this.logs.forEach(log => {
const levelColor = this.getLogLevelColor(log.level);
// Formater le timestamp depuis l'API (format ISO)
const timestamp = log.timestamp
? new Date(log.timestamp).toLocaleString('fr-FR')
: '--';
const logEntry = document.createElement('div');
logEntry.className = 'log-entry';
logEntry.innerHTML = `
${timestamp}
${log.level}
${log.message}
${log.host ? `[${log.host}] ` : ''}
`;
container.appendChild(logEntry);
});
// Auto-scroll to bottom
container.scrollTop = container.scrollHeight;
}
getStatusBadge(status) {
const badges = {
'completed': 'Terminé ',
'running': 'En cours ',
'pending': 'En attente ',
'failed': 'Échoué '
};
return badges[status] || badges['pending'];
}
getLogLevelColor(level) {
const colors = {
'INFO': 'bg-blue-600 text-white',
'WARN': 'bg-yellow-600 text-white',
'ERROR': 'bg-red-600 text-white',
'DEBUG': 'bg-gray-600 text-white'
};
return colors[level] || colors['INFO'];
}
startAnimations() {
// Animate metrics on load
anime({
targets: '.metric-card',
translateY: [50, 0],
opacity: [0, 1],
delay: anime.stagger(200),
duration: 800,
easing: 'easeOutExpo'
});
// Animate host cards
anime({
targets: '.host-card',
translateX: [-30, 0],
opacity: [0, 1],
delay: anime.stagger(100),
duration: 600,
easing: 'easeOutExpo'
});
// Floating animation for hero elements
anime({
targets: '.animate-float',
translateY: [-10, 10],
duration: 3000,
direction: 'alternate',
loop: true,
easing: 'easeInOutSine'
});
}
setupScrollAnimations() {
const observerOptions = {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, observerOptions);
document.querySelectorAll('.fade-in').forEach(el => {
observer.observe(el);
});
}
// Les mises à jour en temps réel sont gérées par WebSocket maintenant
// Public methods for UI interactions
showQuickActions() {
// Construire la liste des groupes Ansible pour le sélecteur
const groupOptions = this.ansibleGroups.map(g =>
`${g} `
).join('');
this.showModal('Actions Rapides - Ansible', `
Cible (groupe ou hôte)
Tous les hôtes
${groupOptions}
Mettre à jour les systèmes (vm-upgrade.yml)
Redémarrer les hôtes (vm-reboot.yml)
Vérifier la santé (health-check.yml)
Sauvegarder la config (backup-config.yml)
`);
}
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}`, `
`);
}
async executeBootstrap(event) {
event.preventDefault();
const formData = new FormData(event.target);
const host = formData.get('host');
const rootPassword = formData.get('root_password');
const automationUser = formData.get('automation_user') || 'automation';
this.closeModal();
this.showLoading();
try {
const result = await this.apiCall('/api/ansible/bootstrap', {
method: 'POST',
body: JSON.stringify({
host: host,
root_password: rootPassword,
automation_user: automationUser
})
});
this.hideLoading();
// Afficher le résultat dans un modal
this.showModal('Bootstrap Réussi', `
Configuration terminée!
L'hôte ${host} est prêt pour Ansible
Détails
${result.stdout || 'Pas de sortie'}
Fermer
`);
this.showNotification(`Bootstrap réussi pour ${host}`, 'success');
await this.loadAllData();
} catch (error) {
this.hideLoading();
// Extraire les détails de l'erreur
let errorDetail = error.message;
let stdout = '';
let stderr = '';
if (error.detail && typeof error.detail === 'object') {
stdout = error.detail.stdout || '';
stderr = error.detail.stderr || '';
}
this.showModal('Erreur Bootstrap', `
Bootstrap échoué
${errorDetail}
${stderr ? `
` : ''}
${stdout ? `
` : ''}
Fermer
`);
}
}
manageHost(hostNameOrId) {
// Support both host name and ID
let host;
if (typeof hostNameOrId === 'number') {
host = this.hosts.find(h => h.id === hostNameOrId);
} else {
host = this.hosts.find(h => h.name === hostNameOrId);
}
if (!host) return;
const lastSeen = host.last_seen
? new Date(host.last_seen).toLocaleString('fr-FR')
: 'Jamais vérifié';
// Bootstrap status
const bootstrapOk = host.bootstrap_ok || false;
const bootstrapDate = host.bootstrap_date
? new Date(host.bootstrap_date).toLocaleString('fr-FR')
: null;
const bootstrapStatusHtml = bootstrapOk
? `
Ansible Ready
(${bootstrapDate || 'N/A'})
`
: `
Non configuré - Bootstrap requis
`;
this.showModal(`Gérer ${host.name}`, `
Informations de l'hôte
Nom:
${host.name}
IP:
${host.ip}
OS:
${host.os}
Statut:
${host.status}
Dernière connexion:
${lastSeen}
Statut Bootstrap Ansible
${bootstrapStatusHtml}
Health Check
Upgrade
Reboot
Backup
${bootstrapOk ? 'Re-Bootstrap SSH' : 'Bootstrap SSH (Requis)'}
`);
}
removeHost(hostId) {
if (confirm('Êtes-vous sûr de vouloir supprimer cet hôte?')) {
this.hosts = this.hosts.filter(h => h.id !== hostId);
this.renderHosts();
this.closeModal();
this.showNotification('Hôte supprimé avec succès!', 'success');
}
}
addTaskToList(taskType) {
const taskNames = {
'upgrade-all': 'Mise à jour système',
'reboot-all': 'Redémarrage système',
'health-check': 'Vérification de santé',
'backup': 'Sauvegarde'
};
const newTask = {
id: Math.max(...this.tasks.map(t => t.id)) + 1,
name: taskNames[taskType] || 'Tâche inconnue',
host: 'Multiple',
status: 'running',
progress: 0,
startTime: new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
duration: '0s'
};
this.tasks.unshift(newTask);
this.renderTasks();
// Simulate task completion
setTimeout(() => {
const task = this.tasks.find(t => t.id === newTask.id);
if (task) {
task.status = 'completed';
task.progress = 100;
this.renderTasks();
}
}, 5000);
}
stopTask(taskId) {
const task = this.tasks.find(t => t.id === taskId);
if (task && task.status === 'running') {
task.status = 'failed';
task.progress = 0;
this.renderTasks();
this.showNotification('Tâche arrêtée', 'warning');
}
}
viewTaskDetails(taskId) {
const task = this.tasks.find(t => t.id === taskId);
if (!task) return;
this.showModal(`Détails de la tâche`, `
${task.name}
Hôte: ${task.host}
Statut: ${this.getStatusBadge(task.status)}
Progression: ${task.progress}%
Durée: ${task.duration}
Logs de la tâche
• Démarrage de la tâche...
• Connexion SSH établie
• Exécution des commandes...
• Tâche terminée avec succès
`);
}
refreshTasks() {
this.showLoading();
setTimeout(() => {
this.hideLoading();
this.showNotification('Tâches rafraîchies', 'success');
}, 1000);
}
clearLogs() {
if (confirm('Êtes-vous sûr de vouloir effacer tous les logs?')) {
this.logs = [];
this.renderLogs();
this.showNotification('Logs effacés avec succès!', 'success');
}
}
exportLogs() {
const logText = this.logs.map(log => `${log.timestamp} [${log.level}] ${log.message}`).join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `homelab-logs-${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
this.showNotification('Logs exportés avec succès!', 'success');
}
// ===== GESTION DES PLAYBOOKS =====
renderPlaybooks() {
const container = document.getElementById('playbooks-list');
if (!container) return;
// Filtrer les playbooks
let filteredPlaybooks = this.playbooks;
// Filtre par catégorie
if (this.currentPlaybookCategoryFilter && this.currentPlaybookCategoryFilter !== 'all') {
filteredPlaybooks = filteredPlaybooks.filter(pb =>
(pb.category || 'general').toLowerCase() === this.currentPlaybookCategoryFilter.toLowerCase()
);
}
// Filtre par recherche
if (this.currentPlaybookSearch) {
const search = this.currentPlaybookSearch.toLowerCase();
filteredPlaybooks = filteredPlaybooks.filter(pb =>
pb.name.toLowerCase().includes(search) ||
pb.filename.toLowerCase().includes(search) ||
(pb.description || '').toLowerCase().includes(search)
);
}
// Mettre à jour le compteur
const countEl = document.getElementById('playbooks-count');
if (countEl) {
countEl.innerHTML = ` ${filteredPlaybooks.length} playbook${filteredPlaybooks.length > 1 ? 's' : ''}`;
}
if (filteredPlaybooks.length === 0) {
container.innerHTML = `
Aucun playbook trouvé
${this.currentPlaybookSearch || this.currentPlaybookCategoryFilter !== 'all'
? '
Essayez de modifier vos filtres
'
: '
Créer un playbook'}
`;
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 = [
`
Tous
`,
...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 `
${icon ? ` ` : ''}${this.escapeHtml(label)}
`;
})
].join('');
container.innerHTML = `
Catégorie:
${buttonsHtml}
`;
}
createPlaybookCardHTML(playbook) {
const category = (playbook.category || 'general').toLowerCase();
const categoryClass = `playbook-category-${category}`;
const categoryLabel = this.getCategoryLabel(category);
// Calculer la taille formatée
const sizeKb = playbook.size ? (playbook.size / 1024).toFixed(1) : '?';
// Calculer le temps relatif
const modifiedAgo = playbook.modified ? this.getRelativeTime(playbook.modified) : 'Date inconnue';
return `
${this.escapeHtml(playbook.filename)}
${categoryLabel}
${playbook.description ? `
${this.escapeHtml(playbook.description)}
` : ''}
${modifiedAgo}
${sizeKb} KB
${playbook.subcategory ? `${playbook.subcategory} ` : ''}
`;
}
getCategoryLabel(category) {
const labels = {
'maintenance': 'Maintenance',
'deploy': 'Deploy',
'backup': 'Backup',
'monitoring': 'Monitoring',
'system': 'System',
'general': 'Général',
'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', `
`);
}
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 = `
YAML valide
Annuler
Sauvegarder
`;
this.showModal(title, modalContent);
// Setup de la validation YAML basique + support de la touche Tab
setTimeout(() => {
const textarea = document.getElementById('playbook-editor-content');
if (textarea) {
textarea.addEventListener('input', () => this.validateYamlContent(textarea.value));
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 2;
}
});
}
}, 100);
}
validateYamlContent(content) {
const statusEl = document.getElementById('yaml-validation-status');
if (!statusEl) return;
// Validation basique du YAML
const errors = [];
const lines = content.split('\n');
lines.forEach((line, index) => {
// Vérifier les tabs (doit utiliser des espaces)
if (line.includes('\t')) {
errors.push(`Ligne ${index + 1}: Utilisez des espaces au lieu des tabs`);
}
// Vérifier l'indentation impaire
const leadingSpaces = line.match(/^(\s*)/)[1].length;
if (leadingSpaces % 2 !== 0 && line.trim()) {
errors.push(`Ligne ${index + 1}: Indentation impaire détectée`);
}
});
if (errors.length > 0) {
statusEl.innerHTML = `
${errors[0]}
`;
} else {
statusEl.innerHTML = `
YAML valide
`;
}
}
async savePlaybook(filename, isNew = false) {
const textarea = document.getElementById('playbook-editor-content');
if (!textarea) return;
const content = textarea.value;
if (!content.trim()) {
this.showNotification('Le contenu ne peut pas être vide', 'warning');
return;
}
this.showLoading();
try {
const result = await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}/content`, {
method: 'PUT',
body: JSON.stringify({ content: content })
});
this.hideLoading();
this.closeModal();
// Retirer la classe spéciale
const modalCard = document.querySelector('#modal .glass-card');
if (modalCard) {
modalCard.classList.remove('playbook-editor-modal');
}
this.showNotification(isNew ? `Playbook "${filename}" créé avec succès` : `Playbook "${filename}" sauvegardé`, 'success');
// Rafraîchir la liste
await this.refreshPlaybooks();
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur sauvegarde: ${error.message}`, 'error');
}
}
async runPlaybook(filename) {
// Ouvrir le modal d'exécution pour un playbook existant
const targetOptions = [
'Tous les hôtes (all) ',
...this.ansibleGroups.map(g => `${g} `)
].join('');
this.showModal(`Exécuter: ${this.escapeHtml(filename)}`, `
`);
}
async executePlaybookFromModal(filename) {
const target = document.getElementById('run-playbook-target')?.value || 'all';
const varsText = document.getElementById('run-playbook-vars')?.value || '';
const checkMode = document.getElementById('run-playbook-check')?.checked || false;
const verbose = document.getElementById('run-playbook-verbose')?.checked || false;
let extraVars = {};
if (varsText.trim()) {
try {
extraVars = JSON.parse(varsText);
} catch (e) {
this.showNotification('Variables JSON invalides', 'error');
return;
}
}
this.closeModal();
this.showLoading();
try {
const result = await this.apiCall('/api/ansible/execute', {
method: 'POST',
body: JSON.stringify({
playbook: filename,
target: target,
extra_vars: extraVars,
check_mode: checkMode,
verbose: verbose
})
});
this.hideLoading();
// Afficher le résultat
const statusColor = result.success ? 'bg-green-900/30 border-green-600' : 'bg-red-900/30 border-red-600';
const statusIcon = result.success ? 'fa-check-circle text-green-500' : 'fa-times-circle text-red-500';
this.showModal(`Résultat: ${filename}`, `
${result.success ? 'Exécution réussie' : 'Échec de l\'exécution'}
Cible: ${target} • Durée: ${result.execution_time || '?'}s
${this.escapeHtml(result.stdout || '(pas de sortie)')}
${result.stderr ? `
Erreurs:
${this.escapeHtml(result.stderr)}
` : ''}
Fermer
`);
this.showNotification(
result.success ? `Playbook exécuté avec succès` : `Échec du playbook`,
result.success ? 'success' : 'error'
);
// Rafraîchir les tâches
await this.loadTaskLogsWithFilters();
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur: ${error.message}`, 'error');
}
}
confirmDeletePlaybook(filename) {
this.showModal('Confirmer la suppression', `
Attention !
Vous êtes sur le point de supprimer le playbook ${this.escapeHtml(filename)} .
Cette action est irréversible.
Supprimer définitivement
Annuler
`);
}
async deletePlaybook(filename) {
this.closeModal();
this.showLoading();
try {
await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}`, {
method: 'DELETE'
});
this.hideLoading();
this.showNotification(`Playbook "${filename}" supprimé`, 'success');
// Rafraîchir la liste
await this.refreshPlaybooks();
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur suppression: ${error.message}`, 'error');
}
}
showModal(title, content, options = {}) {
const modalCard = document.querySelector('#modal .glass-card');
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-content').innerHTML = content;
document.getElementById('modal').classList.remove('hidden');
// Appliquer classe spéciale pour Ad-Hoc console
if (title.includes('Ad-Hoc')) {
modalCard.classList.add('adhoc-modal');
} else {
modalCard.classList.remove('adhoc-modal');
}
// Animate modal appearance
anime({
targets: '#modal .glass-card',
scale: [0.8, 1],
opacity: [0, 1],
duration: 300,
easing: 'easeOutExpo'
});
}
// ===== MÉTHODES DU PLANIFICATEUR (SCHEDULES) =====
renderSchedules() {
const listContainer = document.getElementById('schedules-list');
const emptyState = document.getElementById('schedules-empty');
if (!listContainer) return;
// Filtrer les schedules
let filteredSchedules = [...this.schedules];
if (this.currentScheduleFilter === 'active') {
filteredSchedules = filteredSchedules.filter(s => s.enabled);
} else if (this.currentScheduleFilter === 'paused') {
filteredSchedules = filteredSchedules.filter(s => !s.enabled);
}
if (this.scheduleSearchQuery) {
const query = this.scheduleSearchQuery.toLowerCase();
filteredSchedules = filteredSchedules.filter(s =>
s.name.toLowerCase().includes(query) ||
s.playbook.toLowerCase().includes(query) ||
s.target.toLowerCase().includes(query)
);
}
// Mettre à jour les stats
this.updateSchedulesStats();
// Afficher l'état vide ou la liste
if (this.schedules.length === 0) {
listContainer.innerHTML = '';
emptyState?.classList.remove('hidden');
return;
}
emptyState?.classList.add('hidden');
if (filteredSchedules.length === 0) {
listContainer.innerHTML = `
Aucun schedule trouvé pour ces critères
`;
return;
}
listContainer.innerHTML = filteredSchedules.map(schedule => this.renderScheduleCard(schedule)).join('');
// Mettre à jour les prochaines exécutions
this.renderUpcomingExecutions();
}
renderScheduleCard(schedule) {
const statusClass = schedule.enabled ? 'active' : 'paused';
const statusChipClass = schedule.enabled ? 'active' : 'paused';
const statusText = schedule.enabled ? 'Actif' : 'En pause';
// Formater la prochaine exécution
let nextRunText = '--';
if (schedule.next_run_at) {
const nextRun = new Date(schedule.next_run_at);
nextRunText = this.formatRelativeTime(nextRun);
}
// Formater la dernière exécution
let lastRunHtml = '';
if (schedule.last_run_at) {
const lastStatusIcon = schedule.last_status === 'success' ? '✅' :
schedule.last_status === 'failed' ? '❌' :
schedule.last_status === 'running' ? '🔄' : '';
const lastRunDate = new Date(schedule.last_run_at);
lastRunHtml = `
| Dernier: ${lastStatusIcon} ${this.formatRelativeTime(lastRunDate)} `;
}
// Formater la récurrence
let recurrenceText = 'Exécution unique';
if (schedule.schedule_type === 'recurring' && schedule.recurrence) {
const rec = schedule.recurrence;
if (rec.type === 'daily') {
recurrenceText = `Tous les jours à ${rec.time}`;
} else if (rec.type === 'weekly') {
const days = (rec.days || []).map(d => ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'][d-1]).join(', ');
recurrenceText = `Chaque ${days} à ${rec.time}`;
} else if (rec.type === 'monthly') {
recurrenceText = `Le ${rec.day_of_month || 1} de chaque mois à ${rec.time}`;
} else if (rec.type === 'custom') {
recurrenceText = `Cron: ${rec.cron_expression}`;
}
}
// Tags
const tagsHtml = (schedule.tags || []).map(tag =>
`
${tag} `
).join('');
return `
${schedule.name}
${statusText}
${tagsHtml}
${schedule.description || ''}
${schedule.playbook}
${schedule.target}
${recurrenceText}
Prochaine: ${nextRunText}
${lastRunHtml}
${schedule.enabled ? `
` : `
`}
`;
}
updateSchedulesStats() {
const activeCount = this.schedules.filter(s => s.enabled).length;
const pausedCount = this.schedules.filter(s => !s.enabled).length;
const activeCountEl = document.getElementById('schedules-active-count');
if (activeCountEl) activeCountEl.textContent = activeCount;
const pausedCountEl = document.getElementById('schedules-paused-count');
if (pausedCountEl) pausedCountEl.textContent = pausedCount;
const failuresEl = document.getElementById('schedules-failures-24h');
if (failuresEl) failuresEl.textContent = this.schedulesStats.failures_24h || 0;
// Dashboard widget
const dashboardActiveEl = document.getElementById('dashboard-schedules-active');
if (dashboardActiveEl) dashboardActiveEl.textContent = activeCount;
const dashboardFailuresEl = document.getElementById('dashboard-schedules-failures');
if (dashboardFailuresEl) dashboardFailuresEl.textContent = this.schedulesStats.failures_24h || 0;
// Prochaine exécution
const activeSchedules = this.schedules.filter(s => s.enabled && s.next_run_at);
if (activeSchedules.length > 0) {
activeSchedules.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at));
const nextRun = new Date(activeSchedules[0].next_run_at);
const now = new Date();
const diffMs = nextRun - now;
const nextRunEl = document.getElementById('schedules-next-run');
const dashboardNextEl = document.getElementById('dashboard-schedules-next');
if (diffMs > 0) {
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
let nextText;
if (hours > 0) {
nextText = `${hours}h${mins}m`;
} else {
nextText = `${mins}min`;
}
if (nextRunEl) nextRunEl.textContent = nextText;
if (dashboardNextEl) dashboardNextEl.textContent = nextText;
} else {
if (nextRunEl) nextRunEl.textContent = 'Imminente';
if (dashboardNextEl) dashboardNextEl.textContent = 'Imminent';
}
} else {
const nextRunEl = document.getElementById('schedules-next-run');
const dashboardNextEl = document.getElementById('dashboard-schedules-next');
if (nextRunEl) nextRunEl.textContent = '--:--';
if (dashboardNextEl) dashboardNextEl.textContent = '--';
}
// Update dashboard upcoming schedules
this.updateDashboardUpcomingSchedules();
}
updateDashboardUpcomingSchedules() {
const container = document.getElementById('dashboard-upcoming-schedules');
if (!container) return;
const upcoming = this.schedules
.filter(s => s.enabled && s.next_run_at)
.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at))
.slice(0, 3);
if (upcoming.length === 0) {
container.innerHTML = '
Aucun schedule actif
';
return;
}
container.innerHTML = upcoming.map(s => {
const nextRun = new Date(s.next_run_at);
return `
${nextRun.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
`;
}).join('');
}
renderUpcomingExecutions() {
const container = document.getElementById('schedules-upcoming');
if (!container) return;
const activeSchedules = this.schedules
.filter(s => s.enabled && s.next_run_at)
.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at))
.slice(0, 5);
if (activeSchedules.length === 0) {
container.innerHTML = '
Aucune exécution planifiée
';
return;
}
container.innerHTML = activeSchedules.map(s => {
const nextRun = new Date(s.next_run_at);
return `
${s.name}
${s.playbook} → ${s.target}
${nextRun.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
${nextRun.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })}
`;
}).join('');
}
formatRelativeTime(date) {
const now = new Date();
const diffMs = date - now;
const absDiffMs = Math.abs(diffMs);
const isPast = diffMs < 0;
const mins = Math.floor(absDiffMs / (1000 * 60));
const hours = Math.floor(absDiffMs / (1000 * 60 * 60));
const days = Math.floor(absDiffMs / (1000 * 60 * 60 * 24));
if (days > 0) {
return isPast ? `il y a ${days}j` : `dans ${days}j`;
} else if (hours > 0) {
return isPast ? `il y a ${hours}h` : `dans ${hours}h`;
} else if (mins > 0) {
return isPast ? `il y a ${mins}min` : `dans ${mins}min`;
} else {
return isPast ? 'à l\'instant' : 'imminent';
}
}
filterSchedules(filter) {
this.currentScheduleFilter = filter;
// Mettre à jour les boutons
document.querySelectorAll('.schedule-filter-btn').forEach(btn => {
btn.classList.remove('active', 'bg-purple-600', 'text-white');
btn.classList.add('bg-gray-700', 'text-gray-300');
if (btn.dataset.filter === filter) {
btn.classList.add('active', 'bg-purple-600', 'text-white');
btn.classList.remove('bg-gray-700', 'text-gray-300');
}
});
this.renderSchedules();
}
searchSchedules(query) {
this.scheduleSearchQuery = query;
this.renderSchedules();
}
toggleScheduleView(view) {
const listView = document.getElementById('schedules-list-view');
const calendarView = document.getElementById('schedules-calendar-view');
if (view === 'calendar') {
listView?.classList.add('hidden');
calendarView?.classList.remove('hidden');
this.renderScheduleCalendar();
} else {
listView?.classList.remove('hidden');
calendarView?.classList.add('hidden');
}
}
async refreshSchedules() {
try {
const [schedulesData, statsData] = await Promise.all([
this.apiCall('/api/schedules'),
this.apiCall('/api/schedules/stats')
]);
this.schedules = schedulesData.schedules || [];
this.schedulesStats = statsData.stats || {};
this.schedulesUpcoming = statsData.upcoming || [];
this.renderSchedules();
this.showNotification('Schedules rafraîchis', 'success');
} catch (error) {
this.showNotification('Erreur lors du rafraîchissement', 'error');
}
}
async refreshSchedulesStats() {
try {
const statsData = await this.apiCall('/api/schedules/stats');
this.schedulesStats = statsData.stats || {};
this.schedulesUpcoming = statsData.upcoming || [];
this.updateSchedulesStats();
} catch (error) {
console.error('Erreur rafraîchissement stats schedules:', error);
}
}
// ===== ACTIONS SCHEDULES =====
async runScheduleNow(scheduleId) {
if (!confirm('Exécuter ce schedule immédiatement ?')) return;
try {
this.showLoading();
await this.apiCall(`/api/schedules/${scheduleId}/run`, { method: 'POST' });
this.showNotification('Schedule lancé', 'success');
} catch (error) {
this.showNotification('Erreur lors du lancement', 'error');
} finally {
this.hideLoading();
}
}
async pauseSchedule(scheduleId) {
try {
const result = await this.apiCall(`/api/schedules/${scheduleId}/pause`, { method: 'POST' });
const schedule = this.schedules.find(s => s.id === scheduleId);
if (schedule) schedule.enabled = false;
this.renderSchedules();
this.showNotification(`Schedule mis en pause`, 'success');
} catch (error) {
this.showNotification('Erreur lors de la mise en pause', 'error');
}
}
async resumeSchedule(scheduleId) {
try {
const result = await this.apiCall(`/api/schedules/${scheduleId}/resume`, { method: 'POST' });
const schedule = this.schedules.find(s => s.id === scheduleId);
if (schedule) schedule.enabled = true;
this.renderSchedules();
this.showNotification(`Schedule repris`, 'success');
} catch (error) {
this.showNotification('Erreur lors de la reprise', 'error');
}
}
async deleteSchedule(scheduleId) {
const schedule = this.schedules.find(s => s.id === scheduleId);
if (!schedule) return;
if (!confirm(`Supprimer le schedule "${schedule.name}" ?`)) return;
try {
await this.apiCall(`/api/schedules/${scheduleId}`, { method: 'DELETE' });
this.schedules = this.schedules.filter(s => s.id !== scheduleId);
this.renderSchedules();
this.showNotification(`Schedule supprimé`, 'success');
} catch (error) {
this.showNotification('Erreur lors de la suppression', 'error');
}
}
// ===== MODAL CRÉATION/ÉDITION SCHEDULE =====
async showCreateScheduleModal(prefilledPlaybook = null) {
this.editingScheduleId = null;
this.scheduleModalStep = 1;
// S'assurer que les playbooks sont chargés
if (!this.playbooks || this.playbooks.length === 0) {
try {
const playbooksData = await this.apiCall('/api/ansible/playbooks');
this.playbooks = playbooksData.playbooks || [];
} catch (error) {
console.error('Erreur chargement playbooks:', error);
}
}
const content = this.getScheduleModalContent(null, prefilledPlaybook);
this.showModal('Nouveau Schedule', content, 'schedule-modal');
}
async showEditScheduleModal(scheduleId) {
const schedule = this.schedules.find(s => s.id === scheduleId);
if (!schedule) return;
this.editingScheduleId = scheduleId;
this.scheduleModalStep = 1;
// S'assurer que les playbooks sont chargés
if (!this.playbooks || this.playbooks.length === 0) {
try {
const playbooksData = await this.apiCall('/api/ansible/playbooks');
this.playbooks = playbooksData.playbooks || [];
} catch (error) {
console.error('Erreur chargement playbooks:', error);
}
}
const content = this.getScheduleModalContent(schedule);
this.showModal(`Modifier: ${schedule.name}`, content, 'schedule-modal');
}
getScheduleModalContent(schedule = null, prefilledPlaybook = null) {
const isEdit = !!schedule;
const s = schedule || {};
// Options de playbooks
const playbookOptions = this.playbooks.map(p =>
`
${p.name} (${p.filename}) `
).join('');
// Options de groupes
const groupOptions = this.ansibleGroups.map(g =>
`
${g} `
).join('');
// Options d'hôtes
const hostOptions = this.ansibleHosts.map(h =>
`
${h.name} `
).join('');
// Récurrence
const rec = s.recurrence || {};
const daysChecked = (rec.days || [1]);
return `
Informations de base
Suivant
Quoi exécuter ?
Playbook *
-- Sélectionner un playbook --
${playbookOptions}
Groupe cible
all (tous les hôtes)
${groupOptions}
Hôte cible
${hostOptions}
Timeout (secondes)
Précédent
Suivant
Quand exécuter ?
Précédent
Suivant
Notifications
Notifications désactivées
Les notifications ntfy sont actuellement désactivées dans la configuration du serveur (NTFY_ENABLED=false). Les paramètres ci-dessous seront ignorés.
Précédent
${isEdit ? 'Enregistrer' : 'Créer le schedule'}
`;
}
async scheduleModalNextStep() {
if (this.scheduleModalStep < 4) {
this.scheduleModalStep++;
this.updateScheduleModalStep();
// Si on arrive à l'étape 4 (Notifications), vérifier si NTFY est activé
if (this.scheduleModalStep === 4) {
await this.checkNtfyStatus();
}
}
}
async checkNtfyStatus() {
try {
const config = await this.apiCall('/api/notifications/config');
const warningEl = document.getElementById('ntfy-disabled-warning');
if (warningEl) {
warningEl.classList.toggle('hidden', config.enabled !== false);
}
} catch (error) {
console.error('Erreur vérification statut NTFY:', error);
}
}
scheduleModalPrevStep() {
if (this.scheduleModalStep > 1) {
this.scheduleModalStep--;
this.updateScheduleModalStep();
}
}
updateScheduleModalStep() {
// Mettre à jour les indicateurs
document.querySelectorAll('.schedule-step-dot').forEach(dot => {
const step = parseInt(dot.dataset.step);
dot.classList.remove('active', 'completed');
if (step < this.scheduleModalStep) {
dot.classList.add('completed');
} else if (step === this.scheduleModalStep) {
dot.classList.add('active');
}
});
document.querySelectorAll('.schedule-step-connector').forEach((conn, i) => {
conn.classList.toggle('active', i < this.scheduleModalStep - 1);
});
// Afficher l'étape actuelle
document.querySelectorAll('.schedule-modal-step').forEach(step => {
step.classList.remove('active');
if (parseInt(step.dataset.step) === this.scheduleModalStep) {
step.classList.add('active');
}
});
}
toggleScheduleTargetType(type) {
document.getElementById('schedule-group-select')?.classList.toggle('hidden', type === 'host');
document.getElementById('schedule-host-select')?.classList.toggle('hidden', type === 'group');
}
toggleScheduleType(type) {
document.getElementById('schedule-recurring-options')?.classList.toggle('hidden', type === 'once');
document.getElementById('schedule-once-options')?.classList.toggle('hidden', type === 'recurring');
}
updateRecurrenceOptions() {
const type = document.getElementById('schedule-recurrence-type')?.value;
document.getElementById('recurrence-time')?.classList.toggle('hidden', type === 'custom');
document.getElementById('recurrence-weekly-days')?.classList.toggle('hidden', type !== 'weekly');
document.getElementById('recurrence-monthly-day')?.classList.toggle('hidden', type !== 'monthly');
document.getElementById('recurrence-cron')?.classList.toggle('hidden', type !== 'custom');
}
async validateCronExpression(expression) {
const container = document.getElementById('cron-validation');
if (!container || !expression.trim()) {
if (container) container.innerHTML = '';
return;
}
try {
const result = await this.apiCall(`/api/schedules/validate-cron?expression=${encodeURIComponent(expression)}`);
if (result.valid) {
container.innerHTML = `
Expression valide
Prochaines: ${result.next_runs?.slice(0, 3).map(r => new Date(r).toLocaleString('fr-FR')).join(', ')}
`;
} else {
container.innerHTML = `
${result.error}
`;
}
} catch (error) {
container.innerHTML = `
Erreur de validation
`;
}
}
async saveSchedule() {
const name = document.getElementById('schedule-name')?.value.trim();
const description = document.getElementById('schedule-description')?.value.trim();
const playbook = document.getElementById('schedule-playbook')?.value;
const targetType = document.querySelector('input[name="schedule-target-type"]:checked')?.value || 'group';
const targetGroup = document.getElementById('schedule-target-group')?.value;
const targetHost = document.getElementById('schedule-target-host')?.value;
const timeout = parseInt(document.getElementById('schedule-timeout')?.value) || 3600;
const scheduleType = document.querySelector('input[name="schedule-type"]:checked')?.value || 'recurring';
const enabled = document.getElementById('schedule-enabled')?.checked ?? true;
const notificationType = document.querySelector('input[name="schedule-notification-type"]:checked')?.value || 'all';
// Validation
if (!name || name.length < 3) {
this.showNotification('Le nom doit faire au moins 3 caractères', 'error');
return;
}
if (!playbook) {
this.showNotification('Veuillez sélectionner un playbook', 'error');
return;
}
// Construire les tags
const tags = Array.from(document.querySelectorAll('.schedule-tag-checkbox:checked')).map(cb => cb.value);
// Construire la récurrence
let recurrence = null;
let startAt = null;
if (scheduleType === 'recurring') {
const recType = document.getElementById('schedule-recurrence-type')?.value || 'daily';
const time = document.getElementById('schedule-time')?.value || '02:00';
recurrence = { type: recType, time };
if (recType === 'weekly') {
recurrence.days = Array.from(document.querySelectorAll('.schedule-day-checkbox:checked')).map(cb => parseInt(cb.value));
if (recurrence.days.length === 0) recurrence.days = [1];
} else if (recType === 'monthly') {
recurrence.day_of_month = parseInt(document.getElementById('schedule-day-of-month')?.value) || 1;
} else if (recType === 'custom') {
recurrence.cron_expression = document.getElementById('schedule-cron')?.value;
if (!recurrence.cron_expression) {
this.showNotification('Veuillez entrer une expression cron', 'error');
return;
}
}
} else {
const startAtValue = document.getElementById('schedule-start-at')?.value;
if (startAtValue) {
startAt = new Date(startAtValue).toISOString();
}
}
const payload = {
name,
description: description || null,
playbook,
target_type: targetType,
target: targetType === 'host' ? targetHost : targetGroup,
timeout,
schedule_type: scheduleType,
recurrence,
start_at: startAt,
enabled,
tags,
notification_type: notificationType
};
try {
this.showLoading();
if (this.editingScheduleId) {
await this.apiCall(`/api/schedules/${this.editingScheduleId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
await this.apiCall('/api/schedules', { method: 'POST', body: JSON.stringify(payload) });
}
this.closeModal();
await this.refreshSchedules();
} catch (error) {
this.showNotification(error.message || 'Erreur lors de la sauvegarde', 'error');
} finally {
this.hideLoading();
}
}
async showScheduleHistory(scheduleId) {
const schedule = this.schedules.find(s => s.id === scheduleId);
if (!schedule) return;
try {
const result = await this.apiCall(`/api/schedules/${scheduleId}/runs?limit=50`);
const runs = result.runs || [];
let content;
if (runs.length === 0) {
content = `
Aucune exécution enregistrée
Le schedule n'a pas encore été exécuté.
`;
} else {
content = `
${runs.length} exécution(s) - Taux de succès: ${schedule.run_count > 0 ? Math.round((schedule.success_count / schedule.run_count) * 100) : 0}%
${runs.map(run => {
const startedAt = new Date(run.started_at);
const statusClass = run.status === 'success' ? 'success' :
run.status === 'failed' ? 'failed' :
run.status === 'running' ? 'running' : 'scheduled';
const statusIcon = run.status === 'success' ? 'check-circle' :
run.status === 'failed' ? 'times-circle' :
run.status === 'running' ? 'spinner fa-spin' : 'clock';
return `
${run.status}
${startedAt.toLocaleString('fr-FR')}
${run.duration_seconds ? `
Durée: ${run.duration_seconds.toFixed(1)}s
` : ''}
${run.hosts_impacted > 0 ? `
${run.hosts_impacted} hôte(s) ` : ''}
${run.task_id ? `
Voir tâche ` : ''}
`;
}).join('')}
`;
}
this.showModal(`Historique: ${schedule.name}`, content);
} catch (error) {
this.showNotification('Erreur lors du chargement de l\'historique', 'error');
}
}
// ===== CALENDRIER DES SCHEDULES =====
renderScheduleCalendar() {
const grid = document.getElementById('schedule-calendar-grid');
const titleEl = document.getElementById('schedule-calendar-title');
if (!grid || !titleEl) return;
const year = this.scheduleCalendarMonth.getFullYear();
const month = this.scheduleCalendarMonth.getMonth();
// Titre
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
titleEl.textContent = `${monthNames[month]} ${year}`;
// Premier jour du mois
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Ajuster pour commencer par Lundi (0 = Dimanche dans JS)
let startDay = firstDay.getDay() - 1;
if (startDay < 0) startDay = 6;
// Générer les jours
const days = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
// Jours du mois précédent
const prevMonth = new Date(year, month, 0);
for (let i = startDay - 1; i >= 0; i--) {
days.push({
date: new Date(year, month - 1, prevMonth.getDate() - i),
otherMonth: true
});
}
// Jours du mois actuel
for (let d = 1; d <= lastDay.getDate(); d++) {
const date = new Date(year, month, d);
days.push({
date,
otherMonth: false,
isToday: date.getTime() === today.getTime()
});
}
// Jours du mois suivant
const remainingDays = 42 - days.length;
for (let d = 1; d <= remainingDays; d++) {
days.push({
date: new Date(year, month + 1, d),
otherMonth: true
});
}
// Générer le HTML
grid.innerHTML = days.map(day => {
const dateStr = day.date.toISOString().split('T')[0];
const classes = ['schedule-calendar-day'];
if (day.otherMonth) classes.push('other-month');
if (day.isToday) classes.push('today');
// Événements pour ce jour (simplifiés - prochaines exécutions)
const events = this.schedulesUpcoming.filter(s => {
if (!s.next_run_at) return false;
const runDate = new Date(s.next_run_at).toISOString().split('T')[0];
return runDate === dateStr;
});
return `
${day.date.getDate()}
${events.slice(0, 2).map(e => `
${e.schedule_name}
`).join('')}
${events.length > 2 ? `
+${events.length - 2}
` : ''}
`;
}).join('');
}
prevCalendarMonth() {
this.scheduleCalendarMonth.setMonth(this.scheduleCalendarMonth.getMonth() - 1);
this.renderScheduleCalendar();
}
nextCalendarMonth() {
this.scheduleCalendarMonth.setMonth(this.scheduleCalendarMonth.getMonth() + 1);
this.renderScheduleCalendar();
}
// ===== FIN DES MÉTHODES PLANIFICATEUR =====
closeModal() {
const modal = document.getElementById('modal');
anime({
targets: '#modal .glass-card',
scale: [1, 0.8],
opacity: [1, 0],
duration: 200,
easing: 'easeInExpo',
complete: () => {
modal.classList.add('hidden');
}
});
}
showLoading() {
document.getElementById('loading-overlay').classList.remove('hidden');
}
hideLoading() {
document.getElementById('loading-overlay').classList.add('hidden');
}
showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${
type === 'success' ? 'bg-green-600' :
type === 'warning' ? 'bg-yellow-600' :
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
} text-white`;
notification.innerHTML = `
${message}
`;
document.body.appendChild(notification);
// Animate in
anime({
targets: notification,
translateX: [300, 0],
opacity: [0, 1],
duration: 300,
easing: 'easeOutExpo'
});
// Remove after 3 seconds
setTimeout(() => {
anime({
targets: notification,
translateX: [0, 300],
opacity: [1, 0],
duration: 300,
easing: 'easeInExpo',
complete: () => {
notification.remove();
}
});
}, 3000);
}
}
// Initialize dashboard when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
console.log('Creating DashboardManager...');
window.dashboard = new DashboardManager();
console.log('DashboardManager created. Methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(dashboard)).filter(m => m.includes('Schedule')));
});
// Global functions for onclick handlers
function showQuickActions() {
dashboard.showQuickActions();
}
function executeTask(taskType) {
dashboard.executeTask(taskType);
}
function addHost() {
dashboard.addHost();
}
function refreshTasks() {
dashboard.refreshTasks();
}
function clearLogs() {
dashboard.clearLogs();
}
function exportLogs() {
dashboard.exportLogs();
}
function closeModal() {
dashboard.closeModal();
}
window.showCreateScheduleModal = function(prefilledPlaybook = null) {
if (!window.dashboard) {
return;
}
if (typeof dashboard.showCreateScheduleModal === 'function') {
dashboard.showCreateScheduleModal(prefilledPlaybook);
return;
}
try {
dashboard.editingScheduleId = null;
dashboard.scheduleModalStep = 1;
if (typeof dashboard.getScheduleModalContent === 'function' &&
typeof dashboard.showModal === 'function') {
const content = dashboard.getScheduleModalContent(null, prefilledPlaybook || null);
dashboard.showModal('Nouveau Schedule', content, 'schedule-modal');
}
} catch (e) {
console.error('showCreateScheduleModal fallback error:', e);
}
};