7886 lines
378 KiB
JavaScript
7886 lines
378 KiB
JavaScript
// Homelab Dashboard JavaScript - Intégration API
|
||
class DashboardManager {
|
||
constructor() {
|
||
// Configuration API
|
||
this.apiKey = 'dev-key-12345';
|
||
this.apiBase = window.location.origin;
|
||
|
||
// Données locales (seront remplies par l'API)
|
||
this.hosts = [];
|
||
this.tasks = [];
|
||
this.logs = [];
|
||
this.ansibleHosts = [];
|
||
this.ansibleGroups = [];
|
||
this.playbooks = [];
|
||
|
||
// Logs de tâches depuis les fichiers markdown
|
||
this.taskLogs = [];
|
||
this.taskLogsStats = { total: 0, completed: 0, failed: 0, running: 0, pending: 0 };
|
||
this.taskLogsDates = { years: {} };
|
||
|
||
// Filtres actifs
|
||
this.currentStatusFilter = 'all';
|
||
this.currentDateFilter = { year: '', month: '', day: '' };
|
||
// Sélection de dates via le calendrier (liste de chaînes YYYY-MM-DD)
|
||
this.selectedTaskDates = [];
|
||
this.taskCalendarMonth = new Date();
|
||
// Filtres d'heure
|
||
this.currentHourStart = '';
|
||
this.currentHourEnd = '';
|
||
// Filtre par type de source (scheduled, manual, adhoc)
|
||
this.currentSourceTypeFilter = 'all';
|
||
this.currentGroupFilter = 'all';
|
||
this.currentBootstrapFilter = 'all';
|
||
this.currentCategoryFilter = 'all';
|
||
this.currentSubcategoryFilter = 'all';
|
||
this.currentTargetFilter = 'all';
|
||
|
||
// Pagination côté serveur
|
||
this.tasksTotalCount = 0;
|
||
this.tasksHasMore = false;
|
||
|
||
// Groupes pour la gestion des hôtes
|
||
this.envGroups = [];
|
||
this.roleGroups = [];
|
||
|
||
// Catégories de playbooks
|
||
this.playbookCategories = {};
|
||
|
||
// Filtres playbooks
|
||
this.currentPlaybookCategoryFilter = 'all';
|
||
this.currentPlaybookSearch = '';
|
||
|
||
// Historique des commandes ad-hoc
|
||
this.adhocHistory = [];
|
||
this.adhocCategories = [];
|
||
|
||
// Schedules (Planificateur)
|
||
this.schedules = [];
|
||
this.schedulesStats = { total: 0, active: 0, paused: 0, failures_24h: 0 };
|
||
this.schedulesUpcoming = [];
|
||
this.currentScheduleFilter = 'all';
|
||
this.scheduleSearchQuery = '';
|
||
this.scheduleCalendarMonth = new Date();
|
||
this.editingScheduleId = null;
|
||
this.scheduleModalStep = 1;
|
||
|
||
// WebSocket
|
||
this.ws = null;
|
||
|
||
// Polling des tâches en cours
|
||
this.runningTasksPollingInterval = null;
|
||
this.pollingIntervalMs = 2000; // Polling toutes les 2 secondes
|
||
|
||
// Pagination des tâches
|
||
this.tasksDisplayedCount = 20;
|
||
this.tasksPerPage = 20;
|
||
|
||
this.init();
|
||
}
|
||
|
||
async init() {
|
||
this.setupEventListeners();
|
||
this.setupScrollAnimations();
|
||
this.startAnimations();
|
||
this.loadThemePreference();
|
||
|
||
// Charger les données depuis l'API
|
||
await this.loadAllData();
|
||
|
||
// Connecter WebSocket pour les mises à jour temps réel
|
||
this.connectWebSocket();
|
||
|
||
// Rafraîchir périodiquement les métriques
|
||
setInterval(() => this.loadMetrics(), 30000);
|
||
|
||
// Démarrer le polling des tâches en cours
|
||
this.startRunningTasksPolling();
|
||
}
|
||
|
||
// ===== API CALLS =====
|
||
|
||
async apiCall(endpoint, options = {}) {
|
||
const url = `${this.apiBase}${endpoint}`;
|
||
const defaultOptions = {
|
||
headers: {
|
||
'X-API-Key': this.apiKey,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(url, { ...defaultOptions, ...options });
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
return await response.json();
|
||
} catch (error) {
|
||
console.error(`API Error (${endpoint}):`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async loadAllData() {
|
||
try {
|
||
// Charger en parallèle
|
||
const [hostsData, tasksData, logsData, metricsData, inventoryData, playbooksData, taskLogsData, taskStatsData, taskDatesData, adhocHistoryData, adhocCategoriesData, schedulesData, schedulesStatsData] = await Promise.all([
|
||
this.apiCall('/api/hosts').catch(() => []),
|
||
this.apiCall('/api/tasks').catch(() => []),
|
||
this.apiCall('/api/logs').catch(() => []),
|
||
this.apiCall('/api/metrics').catch(() => ({})),
|
||
this.apiCall('/api/ansible/inventory').catch(() => ({ hosts: [], groups: [] })),
|
||
this.apiCall('/api/ansible/playbooks').catch(() => ({ playbooks: [] })),
|
||
this.apiCall('/api/tasks/logs').catch(() => ({ logs: [], count: 0 })),
|
||
this.apiCall('/api/tasks/logs/stats').catch(() => ({ total: 0, completed: 0, failed: 0, running: 0, pending: 0 })),
|
||
this.apiCall('/api/tasks/logs/dates').catch(() => ({ years: {} })),
|
||
this.apiCall('/api/adhoc/history').catch(() => ({ commands: [], count: 0 })),
|
||
this.apiCall('/api/adhoc/categories').catch(() => ({ categories: [] })),
|
||
this.apiCall('/api/schedules').catch(() => ({ schedules: [], count: 0 })),
|
||
this.apiCall('/api/schedules/stats').catch(() => ({ stats: {}, upcoming: [] }))
|
||
]);
|
||
|
||
this.hosts = hostsData;
|
||
this.tasks = tasksData;
|
||
this.logs = logsData;
|
||
this.ansibleHosts = inventoryData.hosts || [];
|
||
this.ansibleGroups = inventoryData.groups || [];
|
||
this.playbooks = playbooksData.playbooks || [];
|
||
this.playbookCategories = playbooksData.categories || {};
|
||
|
||
// Logs de tâches markdown
|
||
this.taskLogs = taskLogsData.logs || [];
|
||
this.taskLogsStats = taskStatsData;
|
||
this.taskLogsDates = taskDatesData;
|
||
|
||
// Historique ad-hoc
|
||
this.adhocHistory = adhocHistoryData.commands || [];
|
||
this.adhocCategories = adhocCategoriesData.categories || [];
|
||
|
||
// Schedules (Planificateur)
|
||
this.schedules = schedulesData.schedules || [];
|
||
this.schedulesStats = schedulesStatsData.stats || { total: 0, active: 0, paused: 0, failures_24h: 0 };
|
||
this.schedulesUpcoming = schedulesStatsData.upcoming || [];
|
||
|
||
console.log('Data loaded:', {
|
||
taskLogs: this.taskLogs.length,
|
||
taskLogsStats: this.taskLogsStats,
|
||
adhocHistory: this.adhocHistory.length,
|
||
adhocCategories: this.adhocCategories.length,
|
||
schedules: this.schedules.length
|
||
});
|
||
|
||
// Mettre à jour l'affichage
|
||
this.renderHosts();
|
||
this.renderTasks();
|
||
this.renderLogs();
|
||
this.renderPlaybooks();
|
||
this.renderSchedules();
|
||
this.updateMetricsDisplay(metricsData);
|
||
this.updateDateFilters();
|
||
this.updateTaskCounts();
|
||
|
||
} catch (error) {
|
||
console.error('Erreur chargement données:', error);
|
||
this.showNotification('Erreur de connexion à l\'API', 'error');
|
||
}
|
||
}
|
||
|
||
async loadMetrics() {
|
||
try {
|
||
const metrics = await this.apiCall('/api/metrics');
|
||
this.updateMetricsDisplay(metrics);
|
||
} catch (error) {
|
||
console.error('Erreur chargement métriques:', error);
|
||
}
|
||
}
|
||
|
||
updateMetricsDisplay(metrics) {
|
||
if (!metrics) return;
|
||
|
||
const elements = {
|
||
'online-hosts': metrics.online_hosts,
|
||
'total-tasks': metrics.total_tasks,
|
||
'success-rate': `${metrics.success_rate}%`,
|
||
'uptime': `${metrics.uptime}%`
|
||
};
|
||
|
||
Object.entries(elements).forEach(([id, value]) => {
|
||
const el = document.getElementById(id);
|
||
if (el && value !== undefined) {
|
||
el.textContent = value;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===== WEBSOCKET =====
|
||
|
||
connectWebSocket() {
|
||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
|
||
|
||
try {
|
||
this.ws = new WebSocket(wsUrl);
|
||
|
||
this.ws.onopen = () => {
|
||
console.log('WebSocket connecté');
|
||
};
|
||
|
||
this.ws.onmessage = (event) => {
|
||
const data = JSON.parse(event.data);
|
||
this.handleWebSocketMessage(data);
|
||
};
|
||
|
||
this.ws.onclose = () => {
|
||
console.log('WebSocket déconnecté, reconnexion dans 5s...');
|
||
setTimeout(() => this.connectWebSocket(), 5000);
|
||
};
|
||
|
||
this.ws.onerror = (error) => {
|
||
console.error('WebSocket erreur:', error);
|
||
};
|
||
} catch (error) {
|
||
console.error('Erreur WebSocket:', error);
|
||
}
|
||
}
|
||
|
||
handleWebSocketMessage(data) {
|
||
switch (data.type) {
|
||
case 'task_created':
|
||
// Nouvelle tâche créée - mettre à jour immédiatement
|
||
this.handleTaskCreated(data.data);
|
||
break;
|
||
case 'task_completed':
|
||
case 'task_failed':
|
||
// Tâche terminée - mettre à jour et rafraîchir les logs
|
||
this.handleTaskCompleted(data.data);
|
||
break;
|
||
case 'task_cancelled':
|
||
// Tâche annulée - mettre à jour l'UI
|
||
this.handleTaskCancelled(data.data);
|
||
break;
|
||
case 'task_progress':
|
||
// Mise à jour de progression - mettre à jour l'UI dynamiquement
|
||
this.handleTaskProgress(data.data);
|
||
break;
|
||
case 'host_created':
|
||
case 'host_deleted':
|
||
this.loadAllData();
|
||
break;
|
||
case 'new_log':
|
||
case 'logs_cleared':
|
||
this.loadLogs();
|
||
break;
|
||
case 'ansible_execution':
|
||
this.showNotification(
|
||
data.data.success ? 'Playbook exécuté avec succès' : 'Échec du playbook',
|
||
data.data.success ? 'success' : 'error'
|
||
);
|
||
break;
|
||
case 'bootstrap_success':
|
||
this.showNotification(
|
||
`Bootstrap réussi pour ${data.data.host}`,
|
||
'success'
|
||
);
|
||
this.loadAllData();
|
||
break;
|
||
case 'bootstrap_status_updated':
|
||
this.loadAllData();
|
||
break;
|
||
case 'schedule_created':
|
||
this.handleScheduleCreated(data.data);
|
||
break;
|
||
case 'schedule_updated':
|
||
this.handleScheduleUpdated(data.data);
|
||
break;
|
||
case 'schedule_deleted':
|
||
this.handleScheduleDeleted(data.data);
|
||
break;
|
||
case 'schedule_run_started':
|
||
this.handleScheduleRunStarted(data.data);
|
||
break;
|
||
case 'schedule_run_finished':
|
||
this.handleScheduleRunFinished(data.data);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ===== HANDLERS WEBSOCKET SCHEDULES =====
|
||
|
||
handleScheduleCreated(schedule) {
|
||
this.schedules.unshift(schedule);
|
||
this.renderSchedules();
|
||
this.showNotification(`Schedule "${schedule.name}" créé`, 'success');
|
||
}
|
||
|
||
handleScheduleUpdated(schedule) {
|
||
const index = this.schedules.findIndex(s => s.id === schedule.id);
|
||
if (index !== -1) {
|
||
this.schedules[index] = schedule;
|
||
}
|
||
this.renderSchedules();
|
||
}
|
||
|
||
handleScheduleDeleted(data) {
|
||
this.schedules = this.schedules.filter(s => s.id !== data.id);
|
||
this.renderSchedules();
|
||
this.showNotification(`Schedule "${data.name}" supprimé`, 'warning');
|
||
}
|
||
|
||
handleScheduleRunStarted(data) {
|
||
this.showNotification(`Schedule "${data.schedule_name}" démarré`, 'info');
|
||
// Mettre à jour le statut du schedule
|
||
const schedule = this.schedules.find(s => s.id === data.schedule_id);
|
||
if (schedule) {
|
||
schedule.last_status = 'running';
|
||
this.renderSchedules();
|
||
}
|
||
}
|
||
|
||
handleScheduleRunFinished(data) {
|
||
const statusMsg = data.success ? 'terminé avec succès' : 'échoué';
|
||
const notifType = data.success ? 'success' : 'error';
|
||
this.showNotification(`Schedule "${data.schedule_name}" ${statusMsg}`, notifType);
|
||
|
||
// Mettre à jour le schedule
|
||
const schedule = this.schedules.find(s => s.id === data.schedule_id);
|
||
if (schedule && data.run) {
|
||
schedule.last_status = data.run.status;
|
||
schedule.last_run_at = data.run.finished_at;
|
||
}
|
||
this.renderSchedules();
|
||
// Rafraîchir les stats
|
||
this.refreshSchedulesStats();
|
||
}
|
||
|
||
// ===== POLLING DES TÂCHES EN COURS =====
|
||
|
||
startRunningTasksPolling() {
|
||
// Arrêter le polling existant si présent
|
||
this.stopRunningTasksPolling();
|
||
|
||
// Démarrer le polling
|
||
this.runningTasksPollingInterval = setInterval(() => {
|
||
this.pollRunningTasks();
|
||
}, this.pollingIntervalMs);
|
||
|
||
// Exécuter immédiatement une première fois
|
||
this.pollRunningTasks();
|
||
|
||
console.log('Polling des tâches en cours démarré');
|
||
}
|
||
|
||
stopRunningTasksPolling() {
|
||
if (this.runningTasksPollingInterval) {
|
||
clearInterval(this.runningTasksPollingInterval);
|
||
this.runningTasksPollingInterval = null;
|
||
console.log('Polling des tâches en cours arrêté');
|
||
}
|
||
}
|
||
|
||
async pollRunningTasks() {
|
||
try {
|
||
const result = await this.apiCall('/api/tasks/running');
|
||
const runningTasks = result.tasks || [];
|
||
|
||
// Vérifier si des tâches ont changé de statut
|
||
const previousRunningIds = this.tasks
|
||
.filter(t => t.status === 'running' || t.status === 'pending')
|
||
.map(t => t.id);
|
||
const currentRunningIds = runningTasks.map(t => t.id);
|
||
|
||
// Détecter les tâches terminées
|
||
const completedTaskIds = previousRunningIds.filter(id => !currentRunningIds.includes(id));
|
||
|
||
if (completedTaskIds.length > 0) {
|
||
// Des tâches ont été terminées - rafraîchir les logs
|
||
console.log('Tâches terminées détectées:', completedTaskIds);
|
||
await this.refreshTaskLogs();
|
||
}
|
||
|
||
// Mettre à jour les tâches en cours
|
||
this.updateRunningTasks(runningTasks);
|
||
|
||
} catch (error) {
|
||
console.error('Erreur polling tâches:', error);
|
||
}
|
||
}
|
||
|
||
updateRunningTasks(runningTasks) {
|
||
// Mettre à jour la liste des tâches en mémoire
|
||
const nonRunningTasks = this.tasks.filter(t => t.status !== 'running' && t.status !== 'pending');
|
||
this.tasks = [...runningTasks, ...nonRunningTasks];
|
||
|
||
// Mettre à jour l'affichage dynamiquement
|
||
this.updateRunningTasksUI(runningTasks);
|
||
this.updateTaskCounts();
|
||
}
|
||
|
||
updateRunningTasksUI(runningTasks) {
|
||
const container = document.getElementById('tasks-list');
|
||
if (!container) return;
|
||
|
||
// Trouver ou créer la section des tâches en cours
|
||
let runningSection = container.querySelector('.running-tasks-section');
|
||
|
||
if (runningTasks.length === 0) {
|
||
// Supprimer la section si plus de tâches en cours
|
||
if (runningSection) {
|
||
runningSection.remove();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Créer la section si elle n'existe pas
|
||
if (!runningSection) {
|
||
runningSection = document.createElement('div');
|
||
runningSection.className = 'running-tasks-section mb-4';
|
||
runningSection.innerHTML = '<h4 class="text-sm font-semibold text-blue-400 mb-2"><i class="fas fa-spinner fa-spin mr-2"></i>En cours</h4>';
|
||
|
||
// Insérer au début du container (après le header)
|
||
const header = container.querySelector('.flex.flex-col');
|
||
if (header && header.nextSibling) {
|
||
container.insertBefore(runningSection, header.nextSibling);
|
||
} else {
|
||
container.prepend(runningSection);
|
||
}
|
||
}
|
||
|
||
// Mettre à jour le contenu des tâches en cours
|
||
const tasksContainer = runningSection.querySelector('.running-tasks-list') || document.createElement('div');
|
||
tasksContainer.className = 'running-tasks-list space-y-2';
|
||
|
||
tasksContainer.innerHTML = runningTasks.map(task => this.createRunningTaskHTML(task)).join('');
|
||
|
||
if (!runningSection.querySelector('.running-tasks-list')) {
|
||
runningSection.appendChild(tasksContainer);
|
||
}
|
||
|
||
// Mettre à jour le badge "en cours" dans le header
|
||
const runningBadge = container.querySelector('.running-badge');
|
||
if (runningBadge) {
|
||
runningBadge.textContent = `${runningTasks.length} en cours`;
|
||
}
|
||
}
|
||
|
||
createRunningTaskHTML(task) {
|
||
const startTime = task.start_time
|
||
? new Date(task.start_time).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||
: '--';
|
||
|
||
const duration = task.duration || this.calculateDuration(task.start_time);
|
||
const progress = task.progress || 0;
|
||
|
||
return `
|
||
<div class="host-card border-l-4 border-blue-500 task-card-${task.id}" data-task-id="${task.id}">
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex-1">
|
||
<div class="flex items-center space-x-3 mb-1">
|
||
<i class="fas fa-cog fa-spin text-blue-500"></i>
|
||
<h4 class="font-semibold text-white text-sm">${this.escapeHtml(task.name)}</h4>
|
||
<span class="px-2 py-0.5 bg-blue-600 text-xs rounded-full">En cours</span>
|
||
</div>
|
||
<p class="text-xs text-gray-400 ml-6">Cible: ${this.escapeHtml(task.host)}</p>
|
||
<p class="text-xs text-gray-500 ml-6">Début: ${startTime} • Durée: ${duration}</p>
|
||
<div class="w-full bg-gray-700 rounded-full h-1.5 mt-2 ml-6">
|
||
<div class="bg-blue-500 h-1.5 rounded-full transition-all duration-500 animate-pulse"
|
||
style="width: ${progress}%"></div>
|
||
</div>
|
||
<p class="text-xs text-gray-500 ml-6 mt-1">${progress}% complété</p>
|
||
</div>
|
||
<div class="flex flex-col space-y-1 ml-2">
|
||
<button class="p-1.5 bg-gray-700 rounded hover:bg-gray-600 transition-colors"
|
||
onclick="dashboard.viewTaskDetails('${task.id}')" title="Voir les détails">
|
||
<i class="fas fa-eye text-gray-300 text-xs"></i>
|
||
</button>
|
||
<button class="p-1.5 bg-red-700 rounded hover:bg-red-600 transition-colors"
|
||
onclick="dashboard.cancelTask('${task.id}')" title="Annuler la tâche">
|
||
<i class="fas fa-times text-white text-xs"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
calculateDuration(startTime) {
|
||
if (!startTime) return '--';
|
||
const start = new Date(startTime);
|
||
const now = new Date();
|
||
const diffMs = now - start;
|
||
const diffSec = Math.floor(diffMs / 1000);
|
||
|
||
if (diffSec < 60) return `${diffSec}s`;
|
||
const diffMin = Math.floor(diffSec / 60);
|
||
const remainingSec = diffSec % 60;
|
||
if (diffMin < 60) return `${diffMin}m ${remainingSec}s`;
|
||
const diffHour = Math.floor(diffMin / 60);
|
||
const remainingMin = diffMin % 60;
|
||
return `${diffHour}h ${remainingMin}m`;
|
||
}
|
||
|
||
// ===== HANDLERS WEBSOCKET POUR LES TÂCHES =====
|
||
|
||
handleTaskCreated(taskData) {
|
||
console.log('Nouvelle tâche créée:', taskData);
|
||
|
||
// Ajouter la tâche à la liste
|
||
const existingIndex = this.tasks.findIndex(t => t.id === taskData.id);
|
||
if (existingIndex === -1) {
|
||
this.tasks.push(taskData);
|
||
} else {
|
||
this.tasks[existingIndex] = taskData;
|
||
}
|
||
|
||
// Mettre à jour l'UI immédiatement
|
||
this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending'));
|
||
this.updateTaskCounts();
|
||
|
||
// Notification
|
||
this.showNotification(`Tâche "${taskData.name}" démarrée`, 'info');
|
||
}
|
||
|
||
handleTaskProgress(progressData) {
|
||
console.log('Progression tâche:', progressData);
|
||
|
||
// Mettre à jour la tâche dans la liste
|
||
const task = this.tasks.find(t => t.id === progressData.task_id);
|
||
if (task) {
|
||
task.progress = progressData.progress;
|
||
|
||
// Mettre à jour l'UI de cette tâche spécifique
|
||
const taskCard = document.querySelector(`.task-card-${progressData.task_id}`);
|
||
if (taskCard) {
|
||
const progressBar = taskCard.querySelector('.bg-blue-500');
|
||
const progressText = taskCard.querySelector('.text-gray-500.mt-1');
|
||
if (progressBar) {
|
||
progressBar.style.width = `${progressData.progress}%`;
|
||
}
|
||
if (progressText) {
|
||
progressText.textContent = `${progressData.progress}% complété`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
handleTaskCompleted(taskData) {
|
||
console.log('Tâche terminée:', taskData);
|
||
|
||
// Retirer la tâche de la liste des tâches en cours
|
||
this.tasks = this.tasks.filter(t => t.id !== taskData.task_id);
|
||
|
||
// Mettre à jour l'UI
|
||
this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending'));
|
||
|
||
// Rafraîchir les logs de tâches pour voir la tâche terminée
|
||
this.refreshTaskLogs();
|
||
|
||
// Notification
|
||
const status = taskData.status || 'completed';
|
||
const isSuccess = status === 'completed';
|
||
this.showNotification(
|
||
`Tâche terminée: ${isSuccess ? 'Succès' : 'Échec'}`,
|
||
isSuccess ? 'success' : 'error'
|
||
);
|
||
}
|
||
|
||
handleTaskCancelled(taskData) {
|
||
console.log('Tâche annulée:', taskData);
|
||
|
||
// Retirer la tâche de la liste des tâches en cours
|
||
this.tasks = this.tasks.filter(t => String(t.id) !== String(taskData.id));
|
||
|
||
// Mettre à jour l'UI
|
||
this.updateRunningTasksUI(this.tasks.filter(t => t.status === 'running' || t.status === 'pending'));
|
||
|
||
// Rafraîchir les logs de tâches
|
||
this.refreshTaskLogs();
|
||
|
||
// Notification
|
||
this.showNotification('Tâche annulée', 'warning');
|
||
}
|
||
|
||
async loadLogs() {
|
||
try {
|
||
const logsData = await this.apiCall('/api/logs');
|
||
this.logs = logsData;
|
||
this.renderLogs();
|
||
} catch (error) {
|
||
console.error('Erreur chargement logs:', error);
|
||
}
|
||
}
|
||
|
||
setupEventListeners() {
|
||
// Theme toggle
|
||
const themeToggle = document.getElementById('theme-toggle');
|
||
if (themeToggle) {
|
||
themeToggle.addEventListener('click', () => {
|
||
this.toggleTheme();
|
||
});
|
||
}
|
||
|
||
// Initialiser le calendrier de filtrage des tâches
|
||
this.setupTaskDateCalendar();
|
||
|
||
// Navigation est gérée par le script de navigation des pages dans index.html
|
||
}
|
||
|
||
// ===== CALENDRIER DE FILTRAGE DES TÂCHES =====
|
||
setupTaskDateCalendar() {
|
||
const wrapper = document.getElementById('task-date-filter-wrapper');
|
||
const button = document.getElementById('task-date-filter-button');
|
||
const calendar = document.getElementById('task-date-calendar');
|
||
const prevBtn = document.getElementById('task-cal-prev-month');
|
||
const nextBtn = document.getElementById('task-cal-next-month');
|
||
const clearBtn = document.getElementById('task-cal-clear');
|
||
const applyBtn = document.getElementById('task-cal-apply');
|
||
|
||
if (!wrapper || !button || !calendar) {
|
||
return; // Section tâches pas présente
|
||
}
|
||
|
||
// État initial
|
||
this.taskCalendarMonth = new Date();
|
||
this.selectedTaskDates = this.selectedTaskDates || [];
|
||
|
||
const toggleCalendar = (open) => {
|
||
const shouldOpen = typeof open === 'boolean' ? open : calendar.classList.contains('hidden');
|
||
if (shouldOpen) {
|
||
calendar.classList.remove('hidden');
|
||
this.renderTaskCalendar();
|
||
} else {
|
||
calendar.classList.add('hidden');
|
||
}
|
||
};
|
||
|
||
button.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
toggleCalendar();
|
||
});
|
||
|
||
document.addEventListener('click', (event) => {
|
||
if (!wrapper.contains(event.target)) {
|
||
calendar.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
prevBtn?.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
this.changeTaskCalendarMonth(-1);
|
||
});
|
||
|
||
nextBtn?.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
this.changeTaskCalendarMonth(1);
|
||
});
|
||
|
||
clearBtn?.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
this.selectedTaskDates = [];
|
||
this.updateDateFilters();
|
||
this.renderTaskCalendar();
|
||
});
|
||
|
||
applyBtn?.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
this.applyDateFilter();
|
||
calendar.classList.add('hidden');
|
||
});
|
||
|
||
// Premier rendu
|
||
this.updateDateFilters();
|
||
this.renderTaskCalendar();
|
||
}
|
||
|
||
changeTaskCalendarMonth(delta) {
|
||
const base = this.taskCalendarMonth instanceof Date ? this.taskCalendarMonth : new Date();
|
||
const d = new Date(base);
|
||
d.setMonth(d.getMonth() + delta);
|
||
this.taskCalendarMonth = d;
|
||
this.renderTaskCalendar();
|
||
}
|
||
|
||
renderTaskCalendar() {
|
||
const grid = document.getElementById('task-cal-grid');
|
||
const monthLabel = document.getElementById('task-cal-current-month');
|
||
if (!grid || !monthLabel) return;
|
||
|
||
const base = this.taskCalendarMonth instanceof Date ? this.taskCalendarMonth : new Date();
|
||
const year = base.getFullYear();
|
||
const month = base.getMonth();
|
||
|
||
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
|
||
monthLabel.textContent = `${monthNames[month]} ${year}`;
|
||
|
||
grid.innerHTML = '';
|
||
|
||
const firstDayOfMonth = new Date(year, month, 1);
|
||
const firstDayOfWeek = firstDayOfMonth.getDay(); // 0 (dimanche) - 6 (samedi)
|
||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||
|
||
const totalCells = 42; // 6 lignes * 7 colonnes
|
||
for (let i = 0; i < totalCells; i++) {
|
||
const cell = document.createElement('div');
|
||
cell.className = 'flex justify-center items-center py-0.5';
|
||
|
||
let date;
|
||
if (i < firstDayOfWeek) {
|
||
// Jours du mois précédent
|
||
const day = prevMonthLastDay - (firstDayOfWeek - 1 - i);
|
||
date = new Date(year, month - 1, day);
|
||
} else if (i < firstDayOfWeek + daysInMonth) {
|
||
// Jours du mois courant
|
||
const day = i - firstDayOfWeek + 1;
|
||
date = new Date(year, month, day);
|
||
} else {
|
||
// Jours du mois suivant
|
||
const day = i - (firstDayOfWeek + daysInMonth) + 1;
|
||
date = new Date(year, month + 1, day);
|
||
}
|
||
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
|
||
const key = this.getDateKey(date);
|
||
const isCurrentMonth = date.getMonth() === month;
|
||
const isSelected = this.selectedTaskDates.includes(key);
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const isToday = date.getFullYear() === today.getFullYear() &&
|
||
date.getMonth() === today.getMonth() &&
|
||
date.getDate() === today.getDate();
|
||
|
||
let classes = 'w-9 h-9 flex items-center justify-center rounded-full text-xs transition-colors duration-150 ';
|
||
|
||
if (!isCurrentMonth) {
|
||
classes += 'text-gray-600';
|
||
btn.disabled = true;
|
||
} else {
|
||
btn.dataset.date = key;
|
||
if (isSelected) {
|
||
classes += 'bg-purple-600 text-white hover:bg-purple-500 cursor-pointer';
|
||
} else if (isToday) {
|
||
classes += 'border border-purple-400 text-purple-200 hover:bg-gray-800 cursor-pointer';
|
||
} else {
|
||
classes += 'text-gray-200 hover:bg-gray-800 cursor-pointer';
|
||
}
|
||
|
||
btn.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
this.toggleTaskDateSelection(key);
|
||
});
|
||
}
|
||
|
||
btn.className = classes;
|
||
btn.textContent = String(date.getDate());
|
||
cell.appendChild(btn);
|
||
grid.appendChild(cell);
|
||
}
|
||
}
|
||
|
||
toggleTaskDateSelection(key) {
|
||
const index = this.selectedTaskDates.indexOf(key);
|
||
if (index > -1) {
|
||
this.selectedTaskDates.splice(index, 1);
|
||
} else {
|
||
this.selectedTaskDates.push(key);
|
||
}
|
||
// Garder les dates triées
|
||
this.selectedTaskDates.sort();
|
||
this.updateDateFilters();
|
||
this.renderTaskCalendar();
|
||
}
|
||
|
||
getDateKey(date) {
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||
const d = String(date.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
|
||
parseDateKey(key) {
|
||
const [y, m, d] = key.split('-').map(v => parseInt(v, 10));
|
||
return new Date(y, (m || 1) - 1, d || 1);
|
||
}
|
||
|
||
toggleTheme() {
|
||
const body = document.body;
|
||
const currentTheme = body.classList.contains('light-theme') ? 'light' : 'dark';
|
||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||
|
||
if (newTheme === 'light') {
|
||
body.classList.add('light-theme');
|
||
document.getElementById('theme-toggle').innerHTML = '<i class="fas fa-sun text-yellow-400"></i>';
|
||
} else {
|
||
body.classList.remove('light-theme');
|
||
document.getElementById('theme-toggle').innerHTML = '<i class="fas fa-moon text-gray-300"></i>';
|
||
}
|
||
|
||
// Persist theme preference
|
||
localStorage.setItem('theme', newTheme);
|
||
}
|
||
|
||
loadThemePreference() {
|
||
const savedTheme = localStorage.getItem('theme');
|
||
if (savedTheme === 'light') {
|
||
document.body.classList.add('light-theme');
|
||
document.getElementById('theme-toggle').innerHTML = '<i class="fas fa-sun text-yellow-400"></i>';
|
||
}
|
||
}
|
||
|
||
renderHosts() {
|
||
const container = document.getElementById('hosts-list');
|
||
const hostsPageContainer = document.getElementById('hosts-page-list');
|
||
if (!container && !hostsPageContainer) return;
|
||
|
||
// Filtrer les hôtes par groupe si un filtre est actif
|
||
let filteredHosts = this.hosts;
|
||
if (this.currentGroupFilter && this.currentGroupFilter !== 'all') {
|
||
filteredHosts = this.hosts.filter(h =>
|
||
h.groups && h.groups.includes(this.currentGroupFilter)
|
||
);
|
||
}
|
||
|
||
// Filtrer par statut bootstrap si un filtre est actif
|
||
if (this.currentBootstrapFilter && this.currentBootstrapFilter !== 'all') {
|
||
if (this.currentBootstrapFilter === 'ready') {
|
||
filteredHosts = filteredHosts.filter(h => h.bootstrap_ok);
|
||
} else if (this.currentBootstrapFilter === 'not_configured') {
|
||
filteredHosts = filteredHosts.filter(h => !h.bootstrap_ok);
|
||
}
|
||
}
|
||
|
||
// Compter les hôtes par statut bootstrap
|
||
const readyCount = this.hosts.filter(h => h.bootstrap_ok).length;
|
||
const notConfiguredCount = this.hosts.filter(h => !h.bootstrap_ok).length;
|
||
|
||
// Options des groupes pour le filtre
|
||
const groupOptions = this.ansibleGroups.map(g =>
|
||
`<option value="${g}" ${this.currentGroupFilter === g ? 'selected' : ''}>${g}</option>`
|
||
).join('');
|
||
|
||
// Header avec filtres et boutons - Design professionnel
|
||
const headerHtml = `
|
||
<div class="flex flex-col gap-4 mb-6">
|
||
<!-- Ligne 1: Compteur et boutons principaux -->
|
||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||
<div class="flex items-center space-x-3">
|
||
<span class="text-sm text-gray-400">
|
||
<i class="fas fa-server mr-2"></i>${filteredHosts.length}/${this.hosts.length} hôtes
|
||
</span>
|
||
<span class="px-2 py-1 bg-green-600/20 text-green-400 text-xs rounded-full">
|
||
<i class="fas fa-check-circle mr-1"></i>${readyCount} Ready
|
||
</span>
|
||
<span class="px-2 py-1 bg-yellow-600/20 text-yellow-400 text-xs rounded-full">
|
||
<i class="fas fa-exclamation-triangle mr-1"></i>${notConfiguredCount} Non configuré
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center space-x-2">
|
||
<button class="px-3 py-1.5 bg-green-600 text-xs rounded hover:bg-green-500 transition-colors"
|
||
onclick="dashboard.executePlaybookOnGroup()" title="Exécuter un playbook sur le groupe">
|
||
<i class="fas fa-play mr-1"></i>Playbook
|
||
</button>
|
||
<button class="px-3 py-1.5 bg-purple-600 text-xs rounded hover:bg-purple-500 transition-colors" onclick="dashboard.refreshHosts()">
|
||
<i class="fas fa-sync-alt mr-1"></i>Rafraîchir
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Ligne 2: Filtres -->
|
||
<div class="flex flex-wrap items-center gap-2 p-3 bg-gray-800/50 rounded-lg">
|
||
<span class="text-xs text-gray-500 mr-2"><i class="fas fa-filter mr-1"></i>Filtres:</span>
|
||
|
||
<!-- Filtre par statut bootstrap -->
|
||
<div class="flex items-center rounded-lg overflow-hidden border border-gray-600">
|
||
<button onclick="dashboard.filterHostsByBootstrap('all')"
|
||
class="px-3 py-1.5 text-xs transition-colors ${this.currentBootstrapFilter === 'all' || !this.currentBootstrapFilter ? 'bg-purple-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}">
|
||
Tous
|
||
</button>
|
||
<button onclick="dashboard.filterHostsByBootstrap('ready')"
|
||
class="px-3 py-1.5 text-xs transition-colors ${this.currentBootstrapFilter === 'ready' ? 'bg-green-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}">
|
||
<i class="fas fa-check-circle mr-1"></i>Ansible Ready
|
||
</button>
|
||
<button onclick="dashboard.filterHostsByBootstrap('not_configured')"
|
||
class="px-3 py-1.5 text-xs transition-colors ${this.currentBootstrapFilter === 'not_configured' ? 'bg-yellow-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}">
|
||
<i class="fas fa-exclamation-triangle mr-1"></i>Non configuré
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Filtre par groupe -->
|
||
<select id="host-group-filter" onchange="dashboard.filterHostsByGroup(this.value)"
|
||
class="px-3 py-1.5 bg-gray-700 border border-gray-600 rounded-lg text-xs">
|
||
<option value="all" ${this.currentGroupFilter === 'all' ? 'selected' : ''}>Tous les groupes</option>
|
||
${groupOptions}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Apply to both containers
|
||
const containers = [container, hostsPageContainer].filter(c => c);
|
||
containers.forEach(c => c.innerHTML = headerHtml);
|
||
|
||
if (filteredHosts.length === 0) {
|
||
const emptyHtml = `
|
||
<div class="text-center text-gray-500 py-8">
|
||
<i class="fas fa-exclamation-circle text-2xl mb-2"></i>
|
||
<p>Aucun hôte trouvé ${this.currentGroupFilter !== 'all' ? `dans le groupe "${this.currentGroupFilter}"` : ''} ${this.currentBootstrapFilter && this.currentBootstrapFilter !== 'all' ? `avec le statut "${this.currentBootstrapFilter === 'ready' ? 'Ansible Ready' : 'Non configuré'}"` : ''}</p>
|
||
<p class="text-sm mt-2">
|
||
<button onclick="dashboard.showAddHostModal()" class="text-purple-400 hover:text-purple-300">
|
||
<i class="fas fa-plus mr-1"></i>Ajouter un hôte
|
||
</button>
|
||
</p>
|
||
</div>
|
||
`;
|
||
containers.forEach(c => c.innerHTML += emptyHtml);
|
||
return;
|
||
}
|
||
|
||
filteredHosts.forEach(host => {
|
||
const statusClass = `status-${host.status}`;
|
||
|
||
// Formater last_seen
|
||
const lastSeen = host.last_seen
|
||
? new Date(host.last_seen).toLocaleString('fr-FR')
|
||
: 'Jamais vérifié';
|
||
|
||
// Indicateur de bootstrap
|
||
const bootstrapOk = host.bootstrap_ok || false;
|
||
const bootstrapDate = host.bootstrap_date
|
||
? new Date(host.bootstrap_date).toLocaleDateString('fr-FR')
|
||
: null;
|
||
const bootstrapIndicator = bootstrapOk
|
||
? `<span class="px-2 py-0.5 bg-green-600/30 text-green-400 text-xs rounded-full flex items-center" title="Bootstrap OK - ${bootstrapDate || 'Date inconnue'}">
|
||
<i class="fas fa-check-circle mr-1"></i>Ansible Ready
|
||
</span>`
|
||
: `<span class="px-2 py-0.5 bg-yellow-600/30 text-yellow-400 text-xs rounded-full flex items-center" title="Bootstrap requis">
|
||
<i class="fas fa-exclamation-triangle mr-1"></i>Non configuré
|
||
</span>`;
|
||
|
||
// Indicateur de qualité de communication
|
||
const commQuality = this.getHostCommunicationQuality(host);
|
||
const commIndicator = `
|
||
<div class="flex items-center gap-1" title="${commQuality.tooltip}">
|
||
<div class="flex items-center gap-0.5">
|
||
${[1,2,3,4,5].map(i => `
|
||
<div class="w-1 rounded-sm transition-all ${i <= commQuality.level ? commQuality.colorClass : 'bg-gray-600'}"
|
||
style="height: ${4 + i * 2}px;"></div>
|
||
`).join('')}
|
||
</div>
|
||
<span class="text-xs ${commQuality.textClass} ml-1">${commQuality.label}</span>
|
||
</div>
|
||
`;
|
||
|
||
const hostCard = document.createElement('div');
|
||
hostCard.className = 'host-card group';
|
||
|
||
// Séparer les groupes env et role
|
||
const hostGroups = host.groups || [];
|
||
const envGroup = hostGroups.find(g => g.startsWith('env_'));
|
||
const roleGroups = hostGroups.filter(g => g.startsWith('role_'));
|
||
|
||
const envBadge = envGroup
|
||
? `<span class="px-2 py-0.5 bg-blue-600/30 text-blue-400 text-xs rounded-full">${envGroup.replace('env_', '')}</span>`
|
||
: '';
|
||
const roleBadges = roleGroups.map(g =>
|
||
`<span class="px-2 py-0.5 bg-purple-600/30 text-purple-400 text-xs rounded">${g.replace('role_', '')}</span>`
|
||
).join('');
|
||
|
||
hostCard.innerHTML = `
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div class="flex items-start space-x-3 flex-1 min-w-0">
|
||
<div class="mt-1">
|
||
<div class="status-indicator ${statusClass}"></div>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||
<h4 class="font-semibold text-white truncate">${host.name}</h4>
|
||
${bootstrapIndicator}
|
||
</div>
|
||
<p class="text-sm text-gray-400">${host.ip} • ${host.os}${envGroup ? ` (${envGroup})` : ''}</p>
|
||
<div class="flex flex-wrap gap-1 mt-1">
|
||
${roleBadges}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Indicateur qualité + Last seen + Boutons horizontaux -->
|
||
<div class="flex items-center gap-3 flex-shrink-0">
|
||
${commIndicator}
|
||
<span class="text-xs text-gray-500 hidden sm:inline">${lastSeen}</span>
|
||
<div class="flex items-center gap-1">
|
||
<button class="p-1.5 bg-gray-700 rounded hover:bg-blue-600 transition-colors" onclick="dashboard.showEditHostModal('${host.name}')" title="Modifier">
|
||
<i class="fas fa-pen text-gray-300 text-xs"></i>
|
||
</button>
|
||
<button class="p-1.5 bg-gray-700 rounded hover:bg-red-600 transition-colors" onclick="dashboard.confirmDeleteHost('${host.name}')" title="Supprimer">
|
||
<i class="fas fa-trash text-gray-300 text-xs"></i>
|
||
</button>
|
||
<button class="p-1.5 bg-gray-700 rounded hover:bg-gray-600 transition-colors" onclick="dashboard.manageHost('${host.name}')" title="Détails">
|
||
<i class="fas fa-cog text-gray-300 text-xs"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Boutons d'action horizontaux -->
|
||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||
<button class="px-3 py-1.5 bg-purple-600 text-xs rounded-lg hover:bg-purple-500 transition-colors flex items-center" onclick="dashboard.executeHostAction('connect', '${host.name}')">
|
||
<i class="fas fa-heartbeat mr-1"></i>Health Check
|
||
</button>
|
||
<button class="px-3 py-1.5 bg-green-600 text-xs rounded-lg hover:bg-green-500 transition-colors flex items-center" onclick="dashboard.executeHostAction('update', '${host.name}')">
|
||
<i class="fas fa-arrow-up mr-1"></i>Upgrade
|
||
</button>
|
||
<button class="px-3 py-1.5 bg-orange-600 text-xs rounded-lg hover:bg-orange-500 transition-colors flex items-center" onclick="dashboard.executeHostAction('reboot', '${host.name}')">
|
||
<i class="fas fa-redo mr-1"></i>Reboot
|
||
</button>
|
||
<button class="px-3 py-1.5 bg-blue-600 text-xs rounded-lg hover:bg-blue-500 transition-colors flex items-center" onclick="dashboard.executeHostAction('backup', '${host.name}')">
|
||
<i class="fas fa-save mr-1"></i>Backup
|
||
</button>
|
||
<button class="px-3 py-1.5 ${bootstrapOk ? 'bg-gray-600' : 'bg-yellow-600 animate-pulse'} text-xs rounded-lg hover:bg-yellow-500 transition-colors flex items-center" onclick="dashboard.showBootstrapModal('${host.name}', '${host.ip}')">
|
||
<i class="fas fa-tools mr-1"></i>Bootstrap
|
||
</button>
|
||
<button class="px-3 py-1.5 bg-teal-600 text-xs rounded-lg hover:bg-teal-500 transition-colors flex items-center" onclick="dashboard.showPlaybookModalForHost('${host.name}')" title="Exécuter un playbook sur cet hôte">
|
||
<i class="fas fa-play-circle mr-1"></i>Playbook
|
||
</button>
|
||
</div>
|
||
`;
|
||
// Append to all containers
|
||
containers.forEach(c => {
|
||
const clonedCard = hostCard.cloneNode(true);
|
||
c.appendChild(clonedCard);
|
||
});
|
||
});
|
||
}
|
||
|
||
filterHostsByBootstrap(status) {
|
||
this.currentBootstrapFilter = status;
|
||
this.renderHosts();
|
||
}
|
||
|
||
// Calcul de la qualité de communication d'un hôte
|
||
getHostCommunicationQuality(host) {
|
||
// Facteurs de qualité:
|
||
// - Statut actuel (online/offline)
|
||
// - Dernière vérification (last_seen)
|
||
// - Bootstrap configuré
|
||
// - Historique des tâches récentes (si disponible)
|
||
|
||
let score = 0;
|
||
let factors = [];
|
||
|
||
// Statut online = +2 points
|
||
if (host.status === 'online') {
|
||
score += 2;
|
||
factors.push('En ligne');
|
||
} else if (host.status === 'offline') {
|
||
factors.push('Hors ligne');
|
||
}
|
||
|
||
// Bootstrap OK = +1 point
|
||
if (host.bootstrap_ok) {
|
||
score += 1;
|
||
factors.push('Ansible configuré');
|
||
}
|
||
|
||
// Last seen récent = +2 points (moins de 1h), +1 point (moins de 24h)
|
||
if (host.last_seen) {
|
||
const lastSeenDate = new Date(host.last_seen);
|
||
const now = new Date();
|
||
const hoursDiff = (now - lastSeenDate) / (1000 * 60 * 60);
|
||
|
||
if (hoursDiff < 1) {
|
||
score += 2;
|
||
factors.push('Vérifié récemment');
|
||
} else if (hoursDiff < 24) {
|
||
score += 1;
|
||
factors.push('Vérifié aujourd\'hui');
|
||
} else {
|
||
factors.push('Non vérifié récemment');
|
||
}
|
||
} else {
|
||
factors.push('Jamais vérifié');
|
||
}
|
||
|
||
// Convertir le score en niveau (1-5)
|
||
const level = Math.min(5, Math.max(1, Math.round(score)));
|
||
|
||
// Déterminer couleur et label selon le niveau
|
||
let colorClass, textClass, label;
|
||
if (level >= 4) {
|
||
colorClass = 'bg-green-500';
|
||
textClass = 'text-green-400';
|
||
label = 'Excellent';
|
||
} else if (level >= 3) {
|
||
colorClass = 'bg-yellow-500';
|
||
textClass = 'text-yellow-400';
|
||
label = 'Bon';
|
||
} else if (level >= 2) {
|
||
colorClass = 'bg-orange-500';
|
||
textClass = 'text-orange-400';
|
||
label = 'Moyen';
|
||
} else {
|
||
colorClass = 'bg-red-500';
|
||
textClass = 'text-red-400';
|
||
label = 'Faible';
|
||
}
|
||
|
||
return {
|
||
level,
|
||
colorClass,
|
||
textClass,
|
||
label,
|
||
tooltip: factors.join(' • ')
|
||
};
|
||
}
|
||
|
||
// Modal pour exécuter un playbook sur un hôte spécifique
|
||
async showPlaybookModalForHost(hostName) {
|
||
// Récupérer la liste des playbooks compatibles avec cet hôte
|
||
try {
|
||
const pbResult = await this.apiCall(`/api/ansible/playbooks?target=${encodeURIComponent(hostName)}`);
|
||
const playbooks = (pbResult && pbResult.playbooks) ? pbResult.playbooks : [];
|
||
|
||
const playbookOptions = playbooks.map(p => `
|
||
<option value="${p.name}">${p.name}${p.description ? ` - ${p.description}` : ''}</option>
|
||
`).join('');
|
||
|
||
const modalContent = `
|
||
<div class="space-y-4">
|
||
<div class="flex items-center gap-3 p-3 bg-gray-700/50 rounded-lg">
|
||
<i class="fas fa-server text-purple-400 text-xl"></i>
|
||
<div>
|
||
<p class="text-sm text-gray-400">Hôte cible</p>
|
||
<p class="font-semibold text-white">${this.escapeHtml(hostName)}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-3 bg-blue-900/30 border border-blue-600 rounded-lg text-sm">
|
||
<i class="fas fa-info-circle text-blue-400 mr-2"></i>
|
||
Seuls les playbooks compatibles avec cet hôte sont affichés (${playbooks.length} disponible${playbooks.length > 1 ? 's' : ''})
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-play-circle mr-2"></i>Playbook à exécuter
|
||
</label>
|
||
<select id="playbook-select" class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent">
|
||
<option value="">-- Sélectionner un playbook --</option>
|
||
${playbookOptions}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-cog mr-2"></i>Variables supplémentaires (JSON, optionnel)
|
||
</label>
|
||
<textarea id="playbook-extra-vars" rows="3"
|
||
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent font-mono text-sm"
|
||
placeholder='{"key": "value"}'></textarea>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<input type="checkbox" id="playbook-check-mode" class="rounded bg-gray-700 border-gray-600 text-teal-500 focus:ring-teal-500">
|
||
<label for="playbook-check-mode" class="text-sm text-gray-300">
|
||
Mode simulation (--check)
|
||
</label>
|
||
</div>
|
||
|
||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-700 mt-2">
|
||
<button type="button" onclick="dashboard.closeModal()" class="px-4 py-2 bg-gray-600 rounded-lg hover:bg-gray-500 transition-colors text-sm">
|
||
Annuler
|
||
</button>
|
||
<button type="button" onclick="dashboard.executePlaybookOnHost('${this.escapeHtml(hostName)}')" class="px-4 py-2 bg-teal-600 rounded-lg hover:bg-teal-500 transition-colors text-sm inline-flex items-center gap-2">
|
||
<i class="fas fa-play"></i>
|
||
<span>Exécuter</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
this.showModal('Exécuter un Playbook', modalContent);
|
||
} catch (error) {
|
||
this.showNotification(`Erreur chargement playbooks: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async executePlaybookOnHost(hostName) {
|
||
const playbookSelect = document.getElementById('playbook-select');
|
||
const extraVarsInput = document.getElementById('playbook-extra-vars');
|
||
const checkModeInput = document.getElementById('playbook-check-mode');
|
||
|
||
const playbook = playbookSelect?.value;
|
||
if (!playbook) {
|
||
this.showNotification('Veuillez sélectionner un playbook', 'warning');
|
||
return;
|
||
}
|
||
|
||
let extraVars = {};
|
||
if (extraVarsInput?.value.trim()) {
|
||
try {
|
||
extraVars = JSON.parse(extraVarsInput.value);
|
||
} catch (e) {
|
||
this.showNotification('Variables JSON invalides', 'error');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const checkMode = checkModeInput?.checked || false;
|
||
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
const result = await this.apiCall('/api/ansible/execute', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
playbook: playbook,
|
||
target: hostName,
|
||
extra_vars: extraVars,
|
||
check_mode: checkMode
|
||
})
|
||
});
|
||
|
||
this.hideLoading();
|
||
this.showNotification(`Playbook "${playbook}" lancé sur ${hostName}`, 'success');
|
||
|
||
// Rafraîchir les tâches
|
||
await this.loadTaskLogsWithFilters();
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async refreshHosts() {
|
||
this.showLoading();
|
||
try {
|
||
await this.apiCall('/api/hosts/refresh', { method: 'POST' });
|
||
await this.loadAllData();
|
||
this.hideLoading();
|
||
this.showNotification('Hôtes rechargés depuis l\'inventaire Ansible', 'success');
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
filterHostsByGroup(group) {
|
||
this.currentGroupFilter = group;
|
||
this.renderHosts();
|
||
}
|
||
|
||
// ===== GESTION DES HÔTES (CRUD) =====
|
||
|
||
async loadHostGroups() {
|
||
try {
|
||
const result = await this.apiCall('/api/hosts/groups');
|
||
this.envGroups = result.env_groups || [];
|
||
this.roleGroups = result.role_groups || [];
|
||
return result;
|
||
} catch (error) {
|
||
console.error('Erreur chargement groupes:', error);
|
||
return { env_groups: [], role_groups: [] };
|
||
}
|
||
}
|
||
|
||
async showAddHostModal() {
|
||
// Charger les groupes disponibles
|
||
await this.loadHostGroups();
|
||
|
||
const envOptions = this.envGroups.map(g =>
|
||
`<option value="${g}">${g.replace('env_', '')}</option>`
|
||
).join('');
|
||
|
||
const roleCheckboxes = this.roleGroups.map(g => `
|
||
<label class="flex items-center space-x-2 p-2 bg-gray-700/50 rounded hover:bg-gray-700 cursor-pointer">
|
||
<input type="checkbox" name="role_groups" value="${g}" class="form-checkbox bg-gray-600 border-gray-500 rounded text-purple-500">
|
||
<span class="text-sm text-gray-300">${g.replace('role_', '')}</span>
|
||
</label>
|
||
`).join('');
|
||
|
||
this.showModal('Ajouter un Host', `
|
||
<form onsubmit="dashboard.createHost(event)" class="space-y-4">
|
||
<div class="p-3 bg-yellow-900/30 border border-yellow-600 rounded-lg text-sm">
|
||
<i class="fas fa-exclamation-triangle text-yellow-400 mr-2"></i>
|
||
L'hôte sera ajouté au fichier <code class="bg-gray-700 px-1 rounded">hosts.yml</code>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-server mr-1"></i>Nom de l'hôte <span class="text-red-400">*</span>
|
||
</label>
|
||
<input type="text" name="name" required
|
||
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none"
|
||
placeholder="server.domain.home" pattern="[a-zA-Z0-9.\-]+" title="Lettres, chiffres, points et tirets uniquement">
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-network-wired mr-1"></i>Adresse IP (ansible_host)
|
||
</label>
|
||
<input type="text" name="ip"
|
||
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none"
|
||
placeholder="192.168.1.100">
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-layer-group mr-1"></i>Groupe d'environnement <span class="text-red-400">*</span>
|
||
</label>
|
||
<select name="env_group" required class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none">
|
||
<option value="">-- Sélectionner --</option>
|
||
${envOptions}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-tags mr-1"></i>Groupes de rôles
|
||
</label>
|
||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-40 overflow-y-auto p-2 bg-gray-800/50 rounded-lg">
|
||
${roleCheckboxes || '<p class="text-gray-500 text-sm col-span-full">Aucun groupe de rôle disponible</p>'}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex space-x-3 pt-4">
|
||
<button type="submit" class="flex-1 btn-primary bg-purple-600 hover:bg-purple-500 py-3 rounded-lg font-medium">
|
||
<i class="fas fa-save mr-2"></i>Enregistrer
|
||
</button>
|
||
<button type="button" onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</form>
|
||
`);
|
||
}
|
||
|
||
async createHost(event) {
|
||
event.preventDefault();
|
||
const formData = new FormData(event.target);
|
||
|
||
const envGroup = formData.get('env_group');
|
||
if (!envGroup) {
|
||
this.showNotification('Veuillez sélectionner un groupe d\'environnement', 'error');
|
||
return;
|
||
}
|
||
|
||
// Récupérer les rôles sélectionnés
|
||
const roleGroups = [];
|
||
document.querySelectorAll('input[name="role_groups"]:checked').forEach(cb => {
|
||
roleGroups.push(cb.value);
|
||
});
|
||
|
||
const payload = {
|
||
name: formData.get('name'),
|
||
ip: formData.get('ip') || null,
|
||
env_group: envGroup,
|
||
role_groups: roleGroups
|
||
};
|
||
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
const result = await this.apiCall('/api/hosts', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
this.hideLoading();
|
||
this.showNotification(`Hôte "${payload.name}" ajouté avec succès dans hosts.yml`, 'success');
|
||
|
||
// Recharger les données
|
||
await this.loadAllData();
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async showEditHostModal(hostName) {
|
||
// Trouver l'hôte
|
||
const host = this.hosts.find(h => h.name === hostName);
|
||
if (!host) {
|
||
this.showNotification('Hôte non trouvé', 'error');
|
||
return;
|
||
}
|
||
|
||
// Charger les groupes disponibles
|
||
await this.loadHostGroups();
|
||
|
||
// Identifier le groupe d'environnement actuel
|
||
const currentEnvGroup = (host.groups || []).find(g => g.startsWith('env_')) || '';
|
||
const currentRoleGroups = (host.groups || []).filter(g => g.startsWith('role_'));
|
||
|
||
const envOptions = this.envGroups.map(g =>
|
||
`<option value="${g}" ${g === currentEnvGroup ? 'selected' : ''}>${g.replace('env_', '')}</option>`
|
||
).join('');
|
||
|
||
const roleCheckboxes = this.roleGroups.map(g => `
|
||
<label class="flex items-center space-x-2 p-2 bg-gray-700/50 rounded hover:bg-gray-700 cursor-pointer">
|
||
<input type="checkbox" name="role_groups" value="${g}"
|
||
${currentRoleGroups.includes(g) ? 'checked' : ''}
|
||
class="form-checkbox bg-gray-600 border-gray-500 rounded text-purple-500">
|
||
<span class="text-sm text-gray-300">${g.replace('role_', '')}</span>
|
||
</label>
|
||
`).join('');
|
||
|
||
this.showModal(`Modifier: ${hostName}`, `
|
||
<form onsubmit="dashboard.updateHost(event, '${hostName}')" class="space-y-4">
|
||
<div class="p-3 bg-yellow-900/30 border border-yellow-600 rounded-lg text-sm">
|
||
<i class="fas fa-exclamation-triangle text-yellow-400 mr-2"></i>
|
||
Les modifications seront appliquées au fichier <code class="bg-gray-700 px-1 rounded">hosts.yml</code>
|
||
</div>
|
||
|
||
<div class="p-4 bg-gray-800 rounded-lg">
|
||
<div class="flex items-center space-x-3">
|
||
<div class="w-10 h-10 bg-purple-600 rounded-lg flex items-center justify-center">
|
||
<i class="fas fa-server text-white"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-semibold text-white">${hostName}</h4>
|
||
<p class="text-sm text-gray-400">${host.ip}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-network-wired mr-1"></i>Adresse IP (ansible_host)
|
||
</label>
|
||
<input type="text" name="ansible_host" value="${host.ip || ''}"
|
||
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none"
|
||
placeholder="192.168.1.100">
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-layer-group mr-1"></i>Groupe d'environnement
|
||
</label>
|
||
<select name="env_group" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none">
|
||
${envOptions}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-tags mr-1"></i>Groupes de rôles
|
||
</label>
|
||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-40 overflow-y-auto p-2 bg-gray-800/50 rounded-lg">
|
||
${roleCheckboxes || '<p class="text-gray-500 text-sm col-span-full">Aucun groupe de rôle disponible</p>'}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex space-x-3 pt-4">
|
||
<button type="submit" class="flex-1 btn-primary bg-blue-600 hover:bg-blue-500 py-3 rounded-lg font-medium">
|
||
<i class="fas fa-save mr-2"></i>Enregistrer
|
||
</button>
|
||
<button type="button" onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</form>
|
||
`);
|
||
}
|
||
|
||
async updateHost(event, hostName) {
|
||
event.preventDefault();
|
||
const formData = new FormData(event.target);
|
||
|
||
// Récupérer les rôles sélectionnés
|
||
const roleGroups = [];
|
||
document.querySelectorAll('input[name="role_groups"]:checked').forEach(cb => {
|
||
roleGroups.push(cb.value);
|
||
});
|
||
|
||
const payload = {
|
||
env_group: formData.get('env_group') || null,
|
||
role_groups: roleGroups,
|
||
ansible_host: formData.get('ansible_host') || null
|
||
};
|
||
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
const result = await this.apiCall(`/api/hosts/${encodeURIComponent(hostName)}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
this.hideLoading();
|
||
this.showNotification(`Hôte "${hostName}" mis à jour avec succès`, 'success');
|
||
|
||
// Recharger les données
|
||
await this.loadAllData();
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
confirmDeleteHost(hostName) {
|
||
this.showModal('Confirmer la suppression', `
|
||
<div class="space-y-4">
|
||
<div class="p-4 bg-red-900/30 border border-red-600 rounded-lg">
|
||
<div class="flex items-start space-x-3">
|
||
<i class="fas fa-exclamation-triangle text-red-500 text-2xl mt-1"></i>
|
||
<div>
|
||
<h4 class="font-semibold text-red-400">Attention !</h4>
|
||
<p class="text-sm text-gray-300 mt-1">
|
||
Vous êtes sur le point de supprimer l'hôte <strong class="text-white">${hostName}</strong> de l'inventaire Ansible.
|
||
</p>
|
||
<p class="text-sm text-gray-400 mt-2">
|
||
Cette action supprimera l'hôte de tous les groupes et ne peut pas être annulée.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex space-x-3">
|
||
<button onclick="dashboard.deleteHost('${hostName}')" class="flex-1 px-4 py-3 bg-red-600 rounded-lg hover:bg-red-500 transition-colors font-medium">
|
||
<i class="fas fa-trash mr-2"></i>Supprimer définitivement
|
||
</button>
|
||
<button onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async deleteHost(hostName) {
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
await this.apiCall(`/api/hosts/by-name/${encodeURIComponent(hostName)}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
this.hideLoading();
|
||
this.showNotification(`Hôte "${hostName}" supprimé avec succès`, 'success');
|
||
|
||
// Recharger les données
|
||
await this.loadAllData();
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// ===== GESTION DES GROUPES (CRUD) =====
|
||
|
||
async loadGroups() {
|
||
try {
|
||
const result = await this.apiCall('/api/groups');
|
||
return result;
|
||
} catch (error) {
|
||
console.error('Erreur chargement groupes:', error);
|
||
return { groups: [], env_count: 0, role_count: 0 };
|
||
}
|
||
}
|
||
|
||
showAddGroupModal(type) {
|
||
const typeLabel = type === 'env' ? 'environnement' : 'rôle';
|
||
const prefix = type === 'env' ? 'env_' : 'role_';
|
||
const icon = type === 'env' ? 'fa-globe' : 'fa-tags';
|
||
const color = type === 'env' ? 'green' : 'blue';
|
||
|
||
this.showModal(`Ajouter un groupe d'${typeLabel}`, `
|
||
<form onsubmit="dashboard.createGroup(event, '${type}')" class="space-y-4">
|
||
<div class="p-3 bg-${color}-900/30 border border-${color}-600 rounded-lg text-sm">
|
||
<i class="fas ${icon} text-${color}-400 mr-2"></i>
|
||
Le groupe sera ajouté à l'inventaire Ansible avec le préfixe <code class="bg-gray-700 px-1 rounded">${prefix}</code>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas ${icon} mr-1"></i>Nom du groupe <span class="text-red-400">*</span>
|
||
</label>
|
||
<div class="flex items-center">
|
||
<span class="px-3 py-3 bg-gray-600 border border-gray-500 border-r-0 rounded-l-lg text-gray-400">${prefix}</span>
|
||
<input type="text" name="name" required
|
||
class="flex-1 p-3 bg-gray-700 border border-gray-600 rounded-r-lg focus:border-${color}-500 focus:outline-none"
|
||
placeholder="${type === 'env' ? 'production' : 'webserver'}"
|
||
pattern="[a-zA-Z0-9_-]+"
|
||
title="Lettres, chiffres, tirets et underscores uniquement">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex space-x-3 pt-4">
|
||
<button type="submit" class="flex-1 btn-primary bg-${color}-600 hover:bg-${color}-500 py-3 rounded-lg font-medium">
|
||
<i class="fas fa-plus mr-2"></i>Créer le groupe
|
||
</button>
|
||
<button type="button" onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</form>
|
||
`);
|
||
}
|
||
|
||
async createGroup(event, type) {
|
||
event.preventDefault();
|
||
const formData = new FormData(event.target);
|
||
|
||
const payload = {
|
||
name: formData.get('name'),
|
||
type: type
|
||
};
|
||
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
const result = await this.apiCall('/api/groups', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
this.hideLoading();
|
||
this.showNotification(result.message || `Groupe créé avec succès`, 'success');
|
||
|
||
// Recharger les groupes
|
||
await this.loadHostGroups();
|
||
await this.loadAllData();
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async showManageGroupsModal(type) {
|
||
const typeLabel = type === 'env' ? 'environnement' : 'rôle';
|
||
const typeLabelPlural = type === 'env' ? 'environnements' : 'rôles';
|
||
const icon = type === 'env' ? 'fa-globe' : 'fa-tags';
|
||
const color = type === 'env' ? 'green' : 'blue';
|
||
|
||
// Charger les groupes
|
||
const groupsData = await this.loadGroups();
|
||
const groups = groupsData.groups.filter(g => g.type === type);
|
||
|
||
let groupsHtml = '';
|
||
if (groups.length === 0) {
|
||
groupsHtml = `
|
||
<div class="text-center py-8 text-gray-400">
|
||
<i class="fas ${icon} text-4xl mb-3 opacity-50"></i>
|
||
<p>Aucun groupe d'${typeLabel} trouvé</p>
|
||
<button onclick="dashboard.closeModal(); dashboard.showAddGroupModal('${type}')"
|
||
class="mt-4 px-4 py-2 bg-${color}-600 rounded-lg hover:bg-${color}-500 transition-colors">
|
||
<i class="fas fa-plus mr-2"></i>Créer un groupe
|
||
</button>
|
||
</div>
|
||
`;
|
||
} else {
|
||
groupsHtml = `
|
||
<div class="space-y-3 max-h-96 overflow-y-auto">
|
||
${groups.map(g => `
|
||
<div class="flex items-center justify-between p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors">
|
||
<div class="flex items-center space-x-3">
|
||
<div class="w-10 h-10 bg-${color}-600/30 rounded-lg flex items-center justify-center">
|
||
<i class="fas ${icon} text-${color}-400"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-medium text-white">${g.display_name}</h4>
|
||
<p class="text-xs text-gray-400">
|
||
<code class="bg-gray-600 px-1 rounded">${g.name}</code>
|
||
<span class="ml-2">${g.hosts_count} hôte(s)</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center space-x-2">
|
||
<button onclick="dashboard.showEditGroupModal('${g.name}', '${type}')"
|
||
class="p-2 text-blue-400 hover:bg-blue-600/20 rounded-lg transition-colors" title="Modifier">
|
||
<i class="fas fa-edit"></i>
|
||
</button>
|
||
<button onclick="dashboard.confirmDeleteGroup('${g.name}', '${type}', ${g.hosts_count})"
|
||
class="p-2 text-red-400 hover:bg-red-600/20 rounded-lg transition-colors" title="Supprimer">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
this.showModal(`Gérer les ${typeLabelPlural}`, `
|
||
<div class="space-y-4">
|
||
<div class="flex items-center justify-between">
|
||
<p class="text-gray-400 text-sm">${groups.length} groupe(s) d'${typeLabel}</p>
|
||
<button onclick="dashboard.closeModal(); dashboard.showAddGroupModal('${type}')"
|
||
class="px-3 py-1.5 bg-${color}-600 rounded-lg hover:bg-${color}-500 transition-colors text-sm">
|
||
<i class="fas fa-plus mr-1"></i>Ajouter
|
||
</button>
|
||
</div>
|
||
|
||
${groupsHtml}
|
||
|
||
<div class="flex justify-end pt-4 border-t border-gray-700">
|
||
<button onclick="dashboard.closeModal()" class="px-6 py-2 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async showEditGroupModal(groupName, type) {
|
||
const typeLabel = type === 'env' ? 'environnement' : 'rôle';
|
||
const prefix = type === 'env' ? 'env_' : 'role_';
|
||
const icon = type === 'env' ? 'fa-globe' : 'fa-tags';
|
||
const color = type === 'env' ? 'green' : 'blue';
|
||
const displayName = groupName.replace(prefix, '');
|
||
|
||
this.showModal(`Modifier le groupe: ${displayName}`, `
|
||
<form onsubmit="dashboard.updateGroup(event, '${groupName}', '${type}')" class="space-y-4">
|
||
<div class="p-3 bg-yellow-900/30 border border-yellow-600 rounded-lg text-sm">
|
||
<i class="fas fa-exclamation-triangle text-yellow-400 mr-2"></i>
|
||
Le renommage affectera tous les hôtes associés à ce groupe.
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas ${icon} mr-1"></i>Nouveau nom <span class="text-red-400">*</span>
|
||
</label>
|
||
<div class="flex items-center">
|
||
<span class="px-3 py-3 bg-gray-600 border border-gray-500 border-r-0 rounded-l-lg text-gray-400">${prefix}</span>
|
||
<input type="text" name="new_name" required value="${displayName}"
|
||
class="flex-1 p-3 bg-gray-700 border border-gray-600 rounded-r-lg focus:border-${color}-500 focus:outline-none"
|
||
pattern="[a-zA-Z0-9_-]+"
|
||
title="Lettres, chiffres, tirets et underscores uniquement">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex space-x-3 pt-4">
|
||
<button type="submit" class="flex-1 btn-primary bg-blue-600 hover:bg-blue-500 py-3 rounded-lg font-medium">
|
||
<i class="fas fa-save mr-2"></i>Enregistrer
|
||
</button>
|
||
<button type="button" onclick="dashboard.showManageGroupsModal('${type}')" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Retour
|
||
</button>
|
||
</div>
|
||
</form>
|
||
`);
|
||
}
|
||
|
||
async updateGroup(event, groupName, type) {
|
||
event.preventDefault();
|
||
const formData = new FormData(event.target);
|
||
|
||
const payload = {
|
||
new_name: formData.get('new_name')
|
||
};
|
||
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
const result = await this.apiCall(`/api/groups/${encodeURIComponent(groupName)}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
this.hideLoading();
|
||
this.showNotification(result.message || `Groupe modifié avec succès`, 'success');
|
||
|
||
// Recharger les groupes et afficher la liste
|
||
await this.loadHostGroups();
|
||
await this.loadAllData();
|
||
this.showManageGroupsModal(type);
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async confirmDeleteGroup(groupName, type, hostsCount) {
|
||
const typeLabel = type === 'env' ? 'environnement' : 'rôle';
|
||
const prefix = type === 'env' ? 'env_' : 'role_';
|
||
const displayName = groupName.replace(prefix, '');
|
||
|
||
// Charger les autres groupes du même type pour le déplacement
|
||
const groupsData = await this.loadGroups();
|
||
const otherGroups = groupsData.groups.filter(g => g.type === type && g.name !== groupName);
|
||
|
||
let moveOptions = '';
|
||
if (hostsCount > 0 && type === 'env') {
|
||
// Pour les groupes d'environnement avec des hôtes, on doit proposer un déplacement
|
||
moveOptions = `
|
||
<div class="mt-4">
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-exchange-alt mr-1"></i>Déplacer les hôtes vers:
|
||
</label>
|
||
<select id="move-hosts-to" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none">
|
||
${otherGroups.length > 0
|
||
? otherGroups.map(g => `<option value="${g.name}">${g.display_name}</option>`).join('')
|
||
: '<option value="" disabled>Aucun autre groupe disponible</option>'
|
||
}
|
||
</select>
|
||
<p class="text-xs text-gray-400 mt-1">Les ${hostsCount} hôte(s) seront déplacés vers ce groupe.</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
this.showModal('Confirmer la suppression', `
|
||
<div class="space-y-4">
|
||
<div class="p-4 bg-red-900/30 border border-red-600 rounded-lg">
|
||
<div class="flex items-start space-x-3">
|
||
<i class="fas fa-exclamation-triangle text-red-500 text-2xl mt-1"></i>
|
||
<div>
|
||
<h4 class="font-semibold text-red-400">Attention !</h4>
|
||
<p class="text-sm text-gray-300 mt-1">
|
||
Vous êtes sur le point de supprimer le groupe d'${typeLabel} <strong class="text-white">${displayName}</strong>.
|
||
</p>
|
||
${hostsCount > 0 ? `
|
||
<p class="text-sm text-yellow-400 mt-2">
|
||
<i class="fas fa-users mr-1"></i>Ce groupe contient ${hostsCount} hôte(s).
|
||
</p>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${moveOptions}
|
||
|
||
<div class="flex space-x-3 pt-4">
|
||
<button onclick="dashboard.deleteGroup('${groupName}', '${type}')"
|
||
class="flex-1 px-4 py-3 bg-red-600 rounded-lg hover:bg-red-500 transition-colors font-medium"
|
||
${hostsCount > 0 && type === 'env' && otherGroups.length === 0 ? 'disabled class="opacity-50 cursor-not-allowed"' : ''}>
|
||
<i class="fas fa-trash mr-2"></i>Supprimer
|
||
</button>
|
||
<button onclick="dashboard.showManageGroupsModal('${type}')" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async deleteGroup(groupName, type) {
|
||
// Récupérer le groupe de destination si spécifié
|
||
const moveSelect = document.getElementById('move-hosts-to');
|
||
const moveHostsTo = moveSelect ? moveSelect.value : null;
|
||
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
let url = `/api/groups/${encodeURIComponent(groupName)}`;
|
||
if (moveHostsTo) {
|
||
url += `?move_hosts_to=${encodeURIComponent(moveHostsTo)}`;
|
||
}
|
||
|
||
const result = await this.apiCall(url, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
this.hideLoading();
|
||
this.showNotification(result.message || `Groupe supprimé avec succès`, 'success');
|
||
|
||
// Recharger les groupes et afficher la liste
|
||
await this.loadHostGroups();
|
||
await this.loadAllData();
|
||
this.showManageGroupsModal(type);
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async executePlaybookOnGroup() {
|
||
const currentGroup = this.currentGroupFilter;
|
||
|
||
// Charger les playbooks compatibles avec ce groupe
|
||
let compatiblePlaybooks = [];
|
||
try {
|
||
const pbResult = await this.apiCall(`/api/ansible/playbooks?target=${encodeURIComponent(currentGroup)}`);
|
||
compatiblePlaybooks = (pbResult && pbResult.playbooks) ? pbResult.playbooks : [];
|
||
} catch (error) {
|
||
this.showNotification(`Erreur chargement playbooks: ${error.message}`, 'error');
|
||
return;
|
||
}
|
||
|
||
// Générer la liste des playbooks groupés par catégorie
|
||
const categoryColors = {
|
||
'maintenance': 'text-orange-400',
|
||
'monitoring': 'text-green-400',
|
||
'backup': 'text-blue-400',
|
||
'general': 'text-gray-400',
|
||
'testing': 'text-purple-400'
|
||
};
|
||
|
||
let playbooksByCategory = {};
|
||
compatiblePlaybooks.forEach(pb => {
|
||
const cat = pb.category || 'general';
|
||
if (!playbooksByCategory[cat]) playbooksByCategory[cat] = [];
|
||
playbooksByCategory[cat].push(pb);
|
||
});
|
||
|
||
let playbooksHtml = '';
|
||
Object.entries(playbooksByCategory).forEach(([category, playbooks]) => {
|
||
const colorClass = categoryColors[category] || 'text-gray-400';
|
||
playbooksHtml += `
|
||
<div class="mb-4">
|
||
<h5 class="text-xs font-semibold ${colorClass} uppercase mb-2">
|
||
<i class="fas fa-folder mr-1"></i>${category}
|
||
</h5>
|
||
<div class="space-y-2">
|
||
${playbooks.map(pb => `
|
||
<button class="w-full p-3 bg-gray-700 rounded-lg text-left hover:bg-gray-600 transition-colors"
|
||
onclick="dashboard.runPlaybookOnTarget('${pb.filename}', '${currentGroup}')">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<span class="font-medium text-white">${pb.name}</span>
|
||
${pb.description ? `<p class="text-xs text-gray-400 mt-1">${pb.description}</p>` : ''}
|
||
</div>
|
||
<span class="text-xs text-gray-500 px-2 py-1 bg-gray-600 rounded">${pb.subcategory || 'other'}</span>
|
||
</div>
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
this.showModal(`Exécuter un Playbook sur "${currentGroup === 'all' ? 'Tous les hôtes' : currentGroup}"`, `
|
||
<div class="space-y-4">
|
||
<div class="p-3 bg-purple-900/30 border border-purple-600 rounded-lg text-sm">
|
||
<i class="fas fa-info-circle text-purple-400 mr-2"></i>
|
||
Sélectionnez un playbook à exécuter sur <strong>${currentGroup === 'all' ? 'tous les hôtes' : 'le groupe ' + currentGroup}</strong>
|
||
</div>
|
||
<div class="p-3 bg-blue-900/30 border border-blue-600 rounded-lg text-sm">
|
||
<i class="fas fa-filter text-blue-400 mr-2"></i>
|
||
Seuls les playbooks compatibles avec ce ${currentGroup === 'all' ? 'groupe' : 'groupe'} sont affichés (${compatiblePlaybooks.length} disponible${compatiblePlaybooks.length > 1 ? 's' : ''})
|
||
</div>
|
||
<div class="max-h-96 overflow-y-auto">
|
||
${playbooksHtml || '<p class="text-gray-500 text-center py-4">Aucun playbook disponible</p>'}
|
||
</div>
|
||
<button onclick="dashboard.closeModal()" class="w-full px-4 py-2 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async runPlaybookOnTarget(playbook, target) {
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
const result = await this.apiCall('/api/ansible/execute', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
playbook: playbook,
|
||
target: target === 'all' ? 'all' : target,
|
||
check_mode: false,
|
||
verbose: true
|
||
})
|
||
});
|
||
|
||
this.hideLoading();
|
||
|
||
const statusColor = result.success ? 'bg-green-900/30 border-green-600' : 'bg-red-900/30 border-red-600';
|
||
const statusIcon = result.success ? 'fa-check-circle text-green-500' : 'fa-times-circle text-red-500';
|
||
|
||
this.showModal(`Résultat: ${playbook}`, `
|
||
<div class="space-y-4">
|
||
<div class="p-4 ${statusColor} border rounded-lg">
|
||
<div class="flex items-center space-x-3">
|
||
<i class="fas ${statusIcon} text-2xl"></i>
|
||
<div>
|
||
<p class="font-semibold">${result.success ? 'Exécution réussie' : 'Échec de l\'exécution'}</p>
|
||
<p class="text-sm text-gray-400">Durée: ${result.execution_time}s</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="p-4 bg-gray-800 rounded-lg max-h-64 overflow-auto">
|
||
<pre class="text-xs text-gray-300 whitespace-pre-wrap font-mono">${this.escapeHtml(result.stdout || '(pas de sortie)')}</pre>
|
||
</div>
|
||
${result.stderr ? `
|
||
<div class="p-4 bg-red-900/20 rounded-lg max-h-32 overflow-auto">
|
||
<h4 class="text-sm font-semibold text-red-400 mb-2">Erreurs:</h4>
|
||
<pre class="text-xs text-red-300 whitespace-pre-wrap font-mono">${this.escapeHtml(result.stderr)}</pre>
|
||
</div>
|
||
` : ''}
|
||
<button onclick="dashboard.closeModal()" class="w-full btn-primary">Fermer</button>
|
||
</div>
|
||
`);
|
||
|
||
this.showNotification(
|
||
result.success ? `Playbook ${playbook} exécuté avec succès` : `Échec du playbook ${playbook}`,
|
||
result.success ? 'success' : 'error'
|
||
);
|
||
|
||
await this.loadAllData();
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
renderTasks() {
|
||
const container = document.getElementById('tasks-list');
|
||
if (!container) return;
|
||
|
||
// Combiner les tâches en mémoire avec les logs markdown
|
||
const runningTasks = this.tasks.filter(t => t.status === 'running' || t.status === 'pending');
|
||
|
||
// Filtrer les logs selon les filtres actifs
|
||
let filteredLogs = this.taskLogs;
|
||
if (this.currentStatusFilter && this.currentStatusFilter !== 'all') {
|
||
filteredLogs = filteredLogs.filter(log => log.status === this.currentStatusFilter);
|
||
}
|
||
|
||
// Générer les options de catégories
|
||
const categoryOptions = Object.keys(this.playbookCategories).map(cat =>
|
||
`<option value="${cat}" ${this.currentCategoryFilter === cat ? 'selected' : ''}>${cat}</option>`
|
||
).join('');
|
||
|
||
// Générer les options de sous-catégories selon la catégorie sélectionnée
|
||
let subcategoryOptions = '';
|
||
if (this.currentCategoryFilter !== 'all' && this.playbookCategories[this.currentCategoryFilter]) {
|
||
subcategoryOptions = this.playbookCategories[this.currentCategoryFilter].map(sub =>
|
||
`<option value="${sub}" ${this.currentSubcategoryFilter === sub ? 'selected' : ''}>${sub}</option>`
|
||
).join('');
|
||
}
|
||
|
||
// Générer les options de target (groupes + hôtes)
|
||
const groupOptions = this.ansibleGroups.map(g =>
|
||
`<option value="${g}" ${this.currentTargetFilter === g ? 'selected' : ''}>${g} (groupe)</option>`
|
||
).join('');
|
||
const hostOptions = this.hosts.map(h =>
|
||
`<option value="${h.name}" ${this.currentTargetFilter === h.name ? 'selected' : ''}>${h.name}</option>`
|
||
).join('');
|
||
|
||
// Catégories dynamiques pour le filtre
|
||
const taskCategories = ['Playbook', 'Ad-hoc', 'Autre'];
|
||
const taskCategoryOptions = taskCategories.map(cat =>
|
||
`<option value="${cat}" ${this.currentCategoryFilter === cat ? 'selected' : ''}>${cat}</option>`
|
||
).join('');
|
||
|
||
// Types de source pour le filtre
|
||
const sourceTypes = [
|
||
{ value: 'scheduled', label: 'Planifiés' },
|
||
{ value: 'manual', label: 'Manuels' },
|
||
{ value: 'adhoc', label: 'Ad-hoc' }
|
||
];
|
||
const sourceTypeOptions = sourceTypes.map(st =>
|
||
`<option value="${st.value}" ${this.currentSourceTypeFilter === st.value ? 'selected' : ''}>${st.label}</option>`
|
||
).join('');
|
||
|
||
// Vérifier si des filtres sont actifs
|
||
const hasActiveFilters = (this.currentTargetFilter && this.currentTargetFilter !== 'all') ||
|
||
(this.currentCategoryFilter && this.currentCategoryFilter !== 'all') ||
|
||
(this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') ||
|
||
(this.currentHourStart || this.currentHourEnd);
|
||
|
||
// Labels pour les types de source
|
||
const sourceTypeLabels = { scheduled: 'Planifiés', manual: 'Manuels', adhoc: 'Ad-hoc' };
|
||
|
||
// Générer les badges de filtres actifs
|
||
const activeFiltersHtml = hasActiveFilters ? `
|
||
<div class="flex flex-wrap items-center gap-2 mt-2 pt-2 border-t border-gray-700">
|
||
<span class="text-xs text-gray-500">Filtres actifs:</span>
|
||
${this.currentTargetFilter && this.currentTargetFilter !== 'all' ? `
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-purple-600/30 text-purple-300 border border-purple-500/50">
|
||
<i class="fas fa-server mr-1"></i>${this.escapeHtml(this.currentTargetFilter)}
|
||
<button onclick="dashboard.clearTargetFilter()" class="ml-1 hover:text-white" title="Supprimer ce filtre">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</span>
|
||
` : ''}
|
||
${this.currentCategoryFilter && this.currentCategoryFilter !== 'all' ? `
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-600/30 text-blue-300 border border-blue-500/50">
|
||
<i class="fas fa-folder mr-1"></i>${this.escapeHtml(this.currentCategoryFilter)}
|
||
<button onclick="dashboard.clearCategoryFilter()" class="ml-1 hover:text-white" title="Supprimer ce filtre">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</span>
|
||
` : ''}
|
||
${this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all' ? `
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-green-600/30 text-green-300 border border-green-500/50">
|
||
<i class="fas fa-clock mr-1"></i>${sourceTypeLabels[this.currentSourceTypeFilter] || this.currentSourceTypeFilter}
|
||
<button onclick="dashboard.clearSourceTypeFilter()" class="ml-1 hover:text-white" title="Supprimer ce filtre">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</span>
|
||
` : ''}
|
||
${this.currentHourStart || this.currentHourEnd ? `
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-yellow-600/30 text-yellow-300 border border-yellow-500/50">
|
||
<i class="fas fa-clock mr-1"></i>${this.currentHourStart || '00:00'} - ${this.currentHourEnd || '23:59'}
|
||
<button onclick="dashboard.clearHourFilter()" class="ml-1 hover:text-white" title="Supprimer ce filtre">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</span>
|
||
` : ''}
|
||
<button onclick="dashboard.clearAllTaskFilters()" class="text-xs text-gray-400 hover:text-white transition-colors">
|
||
<i class="fas fa-times-circle mr-1"></i>Tout effacer
|
||
</button>
|
||
</div>
|
||
` : '';
|
||
|
||
// Header avec filtres de catégorie, target et bouton console
|
||
const headerHtml = `
|
||
<div class="flex flex-col gap-2 mb-4">
|
||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||
<div class="flex items-center space-x-4">
|
||
<span class="text-sm text-gray-400">
|
||
<i class="fas fa-file-alt mr-2"></i>${filteredLogs.length} log(s)
|
||
</span>
|
||
${runningTasks.length > 0 ? `
|
||
<span class="px-2 py-1 bg-blue-600 text-xs rounded-full animate-pulse">
|
||
${runningTasks.length} en cours
|
||
</span>
|
||
` : ''}
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<select id="task-target-filter" onchange="dashboard.filterTasksByTarget(this.value)"
|
||
class="px-2 py-1.5 bg-gray-700 border border-gray-600 rounded text-xs">
|
||
<option value="all" ${this.currentTargetFilter === 'all' || !this.currentTargetFilter ? 'selected' : ''}>Toutes cibles</option>
|
||
<optgroup label="Groupes">
|
||
${groupOptions}
|
||
</optgroup>
|
||
<optgroup label="Hôtes">
|
||
${hostOptions}
|
||
</optgroup>
|
||
</select>
|
||
<select id="task-category-filter" onchange="dashboard.filterTasksByCategory(this.value)"
|
||
class="px-2 py-1.5 bg-gray-700 border border-gray-600 rounded text-xs">
|
||
<option value="all" ${this.currentCategoryFilter === 'all' || !this.currentCategoryFilter ? 'selected' : ''}>Toutes catégories</option>
|
||
${taskCategoryOptions}
|
||
</select>
|
||
<select id="task-source-type-filter" onchange="dashboard.filterTasksBySourceType(this.value)"
|
||
class="px-2 py-1.5 bg-gray-700 border border-gray-600 rounded text-xs">
|
||
<option value="all" ${this.currentSourceTypeFilter === 'all' || !this.currentSourceTypeFilter ? 'selected' : ''}>Tous types</option>
|
||
${sourceTypeOptions}
|
||
</select>
|
||
<button onclick="dashboard.showAdHocConsole()" class="px-3 py-1.5 bg-purple-600 text-xs rounded hover:bg-purple-500 transition-colors">
|
||
<i class="fas fa-terminal mr-1"></i>Console Ad-Hoc
|
||
</button>
|
||
</div>
|
||
</div>
|
||
${activeFiltersHtml}
|
||
</div>
|
||
`;
|
||
|
||
container.innerHTML = headerHtml;
|
||
|
||
// Afficher d'abord les tâches en cours (section dynamique)
|
||
if (runningTasks.length > 0) {
|
||
const runningSection = document.createElement('div');
|
||
runningSection.className = 'running-tasks-section mb-4';
|
||
runningSection.innerHTML = '<h4 class="text-sm font-semibold text-blue-400 mb-2"><i class="fas fa-spinner fa-spin mr-2"></i>En cours</h4>';
|
||
|
||
const tasksContainer = document.createElement('div');
|
||
tasksContainer.className = 'running-tasks-list space-y-2';
|
||
tasksContainer.innerHTML = runningTasks.map(task => this.createRunningTaskHTML(task)).join('');
|
||
runningSection.appendChild(tasksContainer);
|
||
|
||
container.appendChild(runningSection);
|
||
}
|
||
|
||
// Afficher les logs markdown
|
||
if (filteredLogs.length > 0) {
|
||
const logsSection = document.createElement('div');
|
||
logsSection.id = 'task-logs-section';
|
||
logsSection.innerHTML = '<h4 class="text-sm font-semibold text-gray-400 mb-2 mt-4"><i class="fas fa-history mr-2"></i>Historique des tâches</h4>';
|
||
|
||
// Afficher tous les logs chargés (pagination côté serveur)
|
||
filteredLogs.forEach(log => {
|
||
logsSection.appendChild(this.createTaskLogCard(log));
|
||
});
|
||
container.appendChild(logsSection);
|
||
|
||
// Afficher la pagination si nécessaire (basée sur la pagination serveur)
|
||
const paginationEl = document.getElementById('tasks-pagination');
|
||
if (paginationEl) {
|
||
if (this.tasksHasMore) {
|
||
paginationEl.classList.remove('hidden');
|
||
const remaining = this.tasksTotalCount - filteredLogs.length;
|
||
paginationEl.innerHTML = `
|
||
<button onclick="dashboard.loadMoreTasks()" class="px-6 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 text-sm">
|
||
<i class="fas fa-chevron-down mr-2"></i>Charger plus (${remaining} restantes)
|
||
</button>
|
||
`;
|
||
} else {
|
||
paginationEl.classList.add('hidden');
|
||
}
|
||
}
|
||
} else if (runningTasks.length === 0) {
|
||
container.innerHTML += `
|
||
<div class="text-center text-gray-500 py-8">
|
||
<i class="fas fa-folder-open text-2xl mb-2"></i>
|
||
<p>Aucune tâche trouvée</p>
|
||
<p class="text-sm">Utilisez "Actions Rapides" ou la Console pour lancer une commande</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
async loadMoreTasks() {
|
||
// Charger plus de tâches depuis le serveur (pagination côté serveur)
|
||
const params = new URLSearchParams();
|
||
if (this.currentStatusFilter && this.currentStatusFilter !== 'all') {
|
||
params.append('status', this.currentStatusFilter);
|
||
}
|
||
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
|
||
const firstDate = this.parseDateKey(this.selectedTaskDates[0]);
|
||
params.append('year', String(firstDate.getFullYear()));
|
||
params.append('month', String(firstDate.getMonth() + 1).padStart(2, '0'));
|
||
params.append('day', String(firstDate.getDate()).padStart(2, '0'));
|
||
} else {
|
||
if (this.currentDateFilter.year) params.append('year', this.currentDateFilter.year);
|
||
if (this.currentDateFilter.month) params.append('month', this.currentDateFilter.month);
|
||
if (this.currentDateFilter.day) params.append('day', this.currentDateFilter.day);
|
||
}
|
||
if (this.currentHourStart) params.append('hour_start', this.currentHourStart);
|
||
if (this.currentHourEnd) params.append('hour_end', this.currentHourEnd);
|
||
if (this.currentTargetFilter && this.currentTargetFilter !== 'all') {
|
||
params.append('target', this.currentTargetFilter);
|
||
}
|
||
if (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') {
|
||
params.append('category', this.currentCategoryFilter);
|
||
}
|
||
if (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') {
|
||
params.append('source_type', this.currentSourceTypeFilter);
|
||
}
|
||
|
||
// Pagination: charger la page suivante
|
||
params.append('limit', this.tasksPerPage);
|
||
params.append('offset', this.taskLogs.length);
|
||
|
||
try {
|
||
const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`);
|
||
const newLogs = result.logs || [];
|
||
|
||
// Ajouter les nouveaux logs à la liste existante
|
||
this.taskLogs = [...this.taskLogs, ...newLogs];
|
||
this.tasksTotalCount = result.total_count || this.tasksTotalCount;
|
||
this.tasksHasMore = result.has_more || false;
|
||
this.tasksDisplayedCount = this.taskLogs.length;
|
||
|
||
// Récupérer la section des logs
|
||
const logsSection = document.getElementById('task-logs-section');
|
||
if (!logsSection) {
|
||
this.renderTasks();
|
||
return;
|
||
}
|
||
|
||
// Ajouter les nouvelles tâches au DOM
|
||
for (const log of newLogs) {
|
||
logsSection.appendChild(this.createTaskLogCard(log));
|
||
}
|
||
|
||
// Mettre à jour le bouton de pagination
|
||
const paginationEl = document.getElementById('tasks-pagination');
|
||
if (paginationEl) {
|
||
if (this.tasksHasMore) {
|
||
const remaining = this.tasksTotalCount - this.taskLogs.length;
|
||
paginationEl.innerHTML = `
|
||
<button onclick="dashboard.loadMoreTasks()" class="px-6 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 text-sm">
|
||
<i class="fas fa-chevron-down mr-2"></i>Charger plus (${remaining} restantes)
|
||
</button>
|
||
`;
|
||
} else {
|
||
paginationEl.classList.add('hidden');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Erreur chargement logs supplémentaires:', error);
|
||
}
|
||
}
|
||
|
||
createTaskLogCard(log) {
|
||
const statusColors = {
|
||
'completed': 'border-green-500 bg-green-500/10',
|
||
'failed': 'border-red-500 bg-red-500/10',
|
||
'running': 'border-blue-500 bg-blue-500/10',
|
||
'pending': 'border-yellow-500 bg-yellow-500/10'
|
||
};
|
||
|
||
const statusIcons = {
|
||
'completed': '<i class="fas fa-check-circle text-green-500"></i>',
|
||
'failed': '<i class="fas fa-times-circle text-red-500"></i>',
|
||
'running': '<i class="fas fa-cog fa-spin text-blue-500"></i>',
|
||
'pending': '<i class="fas fa-clock text-yellow-500"></i>'
|
||
};
|
||
|
||
// Formater les heures de début et fin
|
||
const formatTime = (isoString) => {
|
||
if (!isoString || isoString === 'N/A') return null;
|
||
try {
|
||
const date = new Date(isoString);
|
||
if (isNaN(date.getTime())) return null;
|
||
return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// Formater la durée
|
||
const formatDuration = (seconds) => {
|
||
if (!seconds || seconds <= 0) return null;
|
||
if (seconds < 60) return `${seconds} seconde${seconds > 1 ? 's' : ''}`;
|
||
if (seconds < 3600) {
|
||
const mins = Math.floor(seconds / 60);
|
||
const secs = seconds % 60;
|
||
return secs > 0 ? `${mins} minute${mins > 1 ? 's' : ''} ${secs} seconde${secs > 1 ? 's' : ''}` : `${mins} minute${mins > 1 ? 's' : ''}`;
|
||
}
|
||
const hours = Math.floor(seconds / 3600);
|
||
const mins = Math.floor((seconds % 3600) / 60);
|
||
const secs = seconds % 60;
|
||
let result = `${hours} heure${hours > 1 ? 's' : ''}`;
|
||
if (mins > 0) result += ` ${mins} minute${mins > 1 ? 's' : ''}`;
|
||
if (secs > 0) result += ` ${secs} seconde${secs > 1 ? 's' : ''}`;
|
||
return result;
|
||
};
|
||
|
||
const startTime = formatTime(log.start_time);
|
||
const endTime = formatTime(log.end_time);
|
||
const duration = log.duration_seconds ? formatDuration(log.duration_seconds) : (log.duration && log.duration !== 'N/A' ? log.duration : null);
|
||
|
||
// Générer les badges d'hôtes
|
||
const hostsHtml = log.hosts && log.hosts.length > 0
|
||
? `<div class="flex flex-wrap gap-1 mt-2">
|
||
${log.hosts.slice(0, 8).map(host => `
|
||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs border border-green-600/50 bg-green-600/20 text-green-400 cursor-pointer hover:bg-green-600/30 transition-colors"
|
||
onclick="event.stopPropagation(); dashboard.filterByHost('${this.escapeHtml(host)}')" title="Filtrer par ${this.escapeHtml(host)}">
|
||
<i class="fas fa-circle text-green-500 mr-1" style="font-size: 6px;"></i>
|
||
${this.escapeHtml(host)}
|
||
</span>
|
||
`).join('')}
|
||
${log.hosts.length > 8 ? `<span class="text-xs text-gray-500">+${log.hosts.length - 8} autres</span>` : ''}
|
||
</div>`
|
||
: '';
|
||
|
||
// Badge de catégorie
|
||
const categoryBadge = log.category
|
||
? `<span class="px-2 py-0.5 rounded text-xs cursor-pointer hover:opacity-80 transition-opacity ${
|
||
log.category === 'Playbook' ? 'bg-purple-600/30 text-purple-300 border border-purple-500/50' :
|
||
log.category === 'Ad-hoc' ? 'bg-blue-600/30 text-blue-300 border border-blue-500/50' :
|
||
'bg-gray-600/30 text-gray-300 border border-gray-500/50'
|
||
}" onclick="event.stopPropagation(); dashboard.filterByCategory('${this.escapeHtml(log.category)}')" title="Filtrer par catégorie">
|
||
${this.escapeHtml(log.category)}${log.subcategory ? ` / ${this.escapeHtml(log.subcategory)}` : ''}
|
||
</span>`
|
||
: '';
|
||
|
||
// Cible cliquable
|
||
const targetHtml = log.target
|
||
? `<span class="text-purple-400 cursor-pointer hover:text-purple-300 hover:underline transition-colors"
|
||
onclick="event.stopPropagation(); dashboard.filterByTarget('${this.escapeHtml(log.target)}')"
|
||
title="Filtrer par ${this.escapeHtml(log.target)}">
|
||
${this.escapeHtml(log.target)}
|
||
</span>`
|
||
: '';
|
||
|
||
const card = document.createElement('div');
|
||
card.className = `host-card border-l-4 ${statusColors[log.status] || 'border-gray-500'} cursor-pointer hover:bg-opacity-20 transition-all`;
|
||
card.onclick = () => this.viewTaskLogContent(log.id);
|
||
card.innerHTML = `
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div class="flex-1 min-w-0">
|
||
<!-- Ligne 1: Icône, Nom, Status, Catégorie -->
|
||
<div class="flex items-center flex-wrap gap-2 mb-1">
|
||
${statusIcons[log.status] || '<i class="fas fa-question-circle text-gray-500"></i>'}
|
||
<h4 class="font-semibold text-white text-sm truncate">${this.escapeHtml(log.task_name)}</h4>
|
||
${this.getStatusBadge(log.status)}
|
||
${categoryBadge}
|
||
</div>
|
||
<!-- Ligne 2: Date, Cible, Heures, Durée -->
|
||
<div class="ml-6 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-400">
|
||
<span><i class="fas fa-calendar mr-1"></i>${log.date}</span>
|
||
${log.target ? `<span><i class="fas fa-server mr-1"></i>${targetHtml}</span>` : ''}
|
||
${startTime ? `<span><i class="fas fa-play mr-1 text-green-500"></i>${startTime}</span>` : ''}
|
||
${endTime ? `<span><i class="fas fa-stop mr-1 text-red-500"></i>${endTime}</span>` : ''}
|
||
${duration ? `<span class="text-yellow-400"><i class="fas fa-clock mr-1"></i>${duration}</span>` : ''}
|
||
</div>
|
||
<!-- Ligne 3: Hôtes -->
|
||
${hostsHtml}
|
||
</div>
|
||
<!-- Boutons horizontaux -->
|
||
<div class="flex items-center gap-1 flex-shrink-0">
|
||
<button class="p-1.5 bg-gray-700 rounded hover:bg-gray-600 transition-colors"
|
||
onclick="event.stopPropagation(); dashboard.viewTaskLogContent('${log.id}')" title="Voir le contenu">
|
||
<i class="fas fa-eye text-gray-300 text-xs"></i>
|
||
</button>
|
||
<button class="p-1.5 bg-blue-700 rounded hover:bg-blue-600 transition-colors"
|
||
onclick="event.stopPropagation(); dashboard.downloadTaskLog('${log.path}')" title="Télécharger">
|
||
<i class="fas fa-download text-white text-xs"></i>
|
||
</button>
|
||
<button class="p-1.5 bg-red-700 rounded hover:bg-red-600 transition-colors"
|
||
onclick="event.stopPropagation(); dashboard.deleteTaskLog('${log.id}', '${this.escapeHtml(log.filename)}')" title="Supprimer le log">
|
||
<i class="fas fa-trash text-white text-xs"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
return card;
|
||
}
|
||
|
||
// Nouvelles fonctions de filtrage par clic
|
||
filterByHost(host) {
|
||
this.currentTargetFilter = host;
|
||
this.tasksDisplayedCount = this.tasksPerPage;
|
||
this.loadTaskLogsWithFilters();
|
||
this.showNotification(`Filtre appliqué: ${host}`, 'info');
|
||
}
|
||
|
||
filterByTarget(target) {
|
||
this.currentTargetFilter = target;
|
||
this.tasksDisplayedCount = this.tasksPerPage;
|
||
this.loadTaskLogsWithFilters();
|
||
this.showNotification(`Filtre appliqué: ${target}`, 'info');
|
||
}
|
||
|
||
filterByCategory(category) {
|
||
this.currentCategoryFilter = category;
|
||
this.currentSubcategoryFilter = 'all';
|
||
this.tasksDisplayedCount = this.tasksPerPage;
|
||
this.loadTaskLogsWithFilters();
|
||
this.showNotification(`Filtre catégorie: ${category}`, 'info');
|
||
}
|
||
|
||
async viewTaskLogContent(logId) {
|
||
try {
|
||
const result = await this.apiCall(`/api/tasks/logs/${logId}`);
|
||
const parsed = this.parseTaskLogMarkdown(result.content);
|
||
|
||
// Détecter si c'est une sortie de playbook structurée
|
||
const isPlaybookOutput = parsed.output && (
|
||
parsed.output.includes('PLAY [') ||
|
||
parsed.output.includes('TASK [') ||
|
||
parsed.output.includes('PLAY RECAP')
|
||
);
|
||
|
||
// Si c'est un playbook, utiliser la vue structurée
|
||
if (isPlaybookOutput) {
|
||
const parsedPlaybook = this.parseAnsiblePlaybookOutput(parsed.output);
|
||
this.currentParsedOutput = parsedPlaybook;
|
||
this.currentTaskLogRawOutput = parsed.output;
|
||
|
||
// Mémoriser les métadonnées et le titre pour pouvoir revenir au résumé
|
||
this.currentStructuredPlaybookMetadata = {
|
||
duration: parsed.duration || 'N/A',
|
||
date: result.log.date || '',
|
||
target: result.log.target || parsed.target || 'N/A'
|
||
};
|
||
this.currentStructuredPlaybookTitle = `Log: ${result.log.task_name}`;
|
||
|
||
this.showStructuredPlaybookViewModal();
|
||
return;
|
||
}
|
||
|
||
// Sinon, utiliser la vue structurée ad-hoc (similaire aux playbooks)
|
||
const hostOutputs = this.parseOutputByHost(parsed.output);
|
||
this.currentTaskLogHostOutputs = hostOutputs;
|
||
|
||
// Compter les succès/échecs
|
||
const successCount = hostOutputs.filter(h => h.status === 'changed' || h.status === 'success').length;
|
||
const failedCount = hostOutputs.filter(h => h.status === 'failed' || h.status === 'unreachable').length;
|
||
const totalHosts = hostOutputs.length;
|
||
|
||
// Utiliser la vue structurée ad-hoc si plusieurs hôtes
|
||
if (totalHosts > 0) {
|
||
const isSuccess = result.log.status === 'completed';
|
||
const adHocView = this.renderAdHocStructuredView(hostOutputs, {
|
||
taskName: result.log.task_name,
|
||
target: result.log.target || parsed.target || 'N/A',
|
||
duration: parsed.duration || 'N/A',
|
||
returnCode: parsed.returnCode,
|
||
date: result.log.date || '',
|
||
isSuccess: isSuccess,
|
||
error: parsed.error
|
||
});
|
||
|
||
this.currentAdHocMetadata = {
|
||
taskName: result.log.task_name,
|
||
target: result.log.target || parsed.target || 'N/A',
|
||
duration: parsed.duration || 'N/A',
|
||
returnCode: parsed.returnCode,
|
||
date: result.log.date || '',
|
||
isSuccess: isSuccess,
|
||
error: parsed.error
|
||
};
|
||
this.currentAdHocTitle = `Log: ${result.log.task_name}`;
|
||
|
||
this.showModal(this.currentAdHocTitle, `
|
||
<div class="playbook-execution-viewer" style="max-width: 100%;">
|
||
${adHocView}
|
||
<div class="mt-4">
|
||
<button onclick="dashboard.closeModal()" class="w-full px-4 py-3 bg-purple-600 rounded-xl hover:bg-purple-500 transition-colors font-medium">
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
return;
|
||
}
|
||
|
||
// Déterminer le statut global
|
||
const isSuccess = result.log.status === 'completed';
|
||
const statusConfig = {
|
||
completed: { icon: 'fa-check-circle', color: 'green', text: 'Succès' },
|
||
failed: { icon: 'fa-times-circle', color: 'red', text: 'Échoué' },
|
||
running: { icon: 'fa-spinner fa-spin', color: 'blue', text: 'En cours' },
|
||
pending: { icon: 'fa-clock', color: 'yellow', text: 'En attente' }
|
||
};
|
||
const status = statusConfig[result.log.status] || statusConfig.failed;
|
||
|
||
// Générer les onglets des hôtes
|
||
let hostTabsHtml = '';
|
||
if (hostOutputs.length > 1 || (hostOutputs.length === 1 && hostOutputs[0].hostname !== 'output')) {
|
||
hostTabsHtml = `
|
||
<div class="mb-3">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="text-xs font-medium text-gray-400 uppercase tracking-wide">
|
||
<i class="fas fa-server mr-1"></i>Sortie par hôte
|
||
</span>
|
||
<span class="text-xs text-gray-500">
|
||
(${totalHosts} hôtes: <span class="text-green-400">${successCount} OK</span>${failedCount > 0 ? `, <span class="text-red-400">${failedCount} échec</span>` : ''})
|
||
</span>
|
||
<button type="button" onclick="dashboard.showAllTaskLogHostsOutput()"
|
||
class="ml-auto text-xs text-purple-400 hover:text-purple-300 transition-colors">
|
||
<i class="fas fa-eye mr-1"></i>Voir tout
|
||
</button>
|
||
</div>
|
||
<div class="flex flex-wrap gap-1.5">
|
||
${hostOutputs.map((host, index) => {
|
||
const hostStatusColor = (host.status === 'changed' || host.status === 'success')
|
||
? 'bg-green-600/80 hover:bg-green-500 border-green-500'
|
||
: (host.status === 'failed' || host.status === 'unreachable')
|
||
? 'bg-red-600/80 hover:bg-red-500 border-red-500'
|
||
: 'bg-gray-600/80 hover:bg-gray-500 border-gray-500';
|
||
const hostStatusIcon = (host.status === 'changed' || host.status === 'success')
|
||
? 'fa-check'
|
||
: (host.status === 'failed' || host.status === 'unreachable')
|
||
? 'fa-times'
|
||
: 'fa-minus';
|
||
return `
|
||
<button type="button"
|
||
onclick="dashboard.switchTaskLogHostTab(${index})"
|
||
data-tasklog-host-tab="${index}"
|
||
class="tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border ${index === 0 ? hostStatusColor + ' text-white' : 'bg-gray-800 text-gray-400 hover:text-white border-gray-700'}">
|
||
<i class="fas ${hostStatusIcon} text-[10px]"></i>
|
||
<span class="truncate max-w-[140px]">${this.escapeHtml(host.hostname)}</span>
|
||
</button>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Contenu du modal amélioré
|
||
const modalContent = `
|
||
<div class="task-log-viewer">
|
||
<!-- Header du résultat -->
|
||
<div class="flex items-center justify-between p-4 rounded-t-xl mb-0 ${isSuccess ? 'bg-green-900/30 border-b border-green-800/50' : 'bg-red-900/30 border-b border-red-800/50'}">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-10 h-10 rounded-xl flex items-center justify-center ${isSuccess ? 'bg-green-900/50' : 'bg-red-900/50'}">
|
||
<i class="fas ${status.icon} text-lg ${isSuccess ? 'text-green-400' : 'text-red-400'}"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-semibold text-white">Résultat d'exécution</h4>
|
||
<p class="text-sm ${isSuccess ? 'text-green-400' : 'text-red-400'}">
|
||
${status.text} • Cible: <span class="text-gray-300">${this.escapeHtml(result.log.target || parsed.target || 'N/A')}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<div class="flex items-center gap-1.5 px-3 py-1.5 bg-gray-800/80 rounded-lg text-xs">
|
||
<i class="fas fa-clock text-gray-400"></i>
|
||
<span class="text-white font-medium">${parsed.duration || 'N/A'}</span>
|
||
</div>
|
||
${parsed.returnCode !== undefined ? `
|
||
<div class="flex items-center gap-1.5 px-3 py-1.5 bg-gray-800/80 rounded-lg text-xs">
|
||
<i class="fas fa-hashtag text-gray-400"></i>
|
||
<span class="text-white font-medium">Code: ${parsed.returnCode}</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Corps du résultat -->
|
||
<div class="bg-gray-900/80 rounded-b-xl border border-gray-800 border-t-0">
|
||
<div class="p-4">
|
||
<!-- Onglets des hôtes -->
|
||
${hostTabsHtml}
|
||
|
||
<!-- Zone de sortie terminal -->
|
||
<div class="relative">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">
|
||
<i class="fas fa-terminal mr-1"></i>Sortie
|
||
</span>
|
||
<button onclick="dashboard.copyTaskLogOutput()" class="text-xs text-gray-400 hover:text-white transition-colors">
|
||
<i class="fas fa-copy mr-1"></i>Copier
|
||
</button>
|
||
</div>
|
||
<pre id="tasklog-output" class="text-sm bg-black/60 p-4 rounded-lg overflow-auto font-mono text-gray-300 leading-relaxed border border-gray-800" style="max-height: 350px;">${this.formatAnsibleOutput(hostOutputs.length > 0 ? hostOutputs[0].output : parsed.output, isSuccess)}</pre>
|
||
</div>
|
||
|
||
<!-- Section erreurs si présentes -->
|
||
${parsed.error ? `
|
||
<div class="mt-4">
|
||
<details class="group" open>
|
||
<summary class="flex items-center gap-2 mb-2 cursor-pointer list-none">
|
||
<span class="text-xs font-medium text-red-400 uppercase tracking-wide">
|
||
<i class="fas fa-exclamation-triangle mr-1"></i>Erreurs
|
||
<i class="fas fa-chevron-down ml-1 text-[10px] transition-transform group-open:rotate-180"></i>
|
||
</span>
|
||
</summary>
|
||
<pre class="text-sm bg-red-950/30 p-4 rounded-lg overflow-auto font-mono text-red-300 leading-relaxed border border-red-900/50" style="max-height: 150px;">${this.escapeHtml(parsed.error)}</pre>
|
||
</details>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bouton fermer -->
|
||
<div class="mt-4">
|
||
<button onclick="dashboard.closeModal()" class="w-full px-4 py-3 bg-purple-600 rounded-xl hover:bg-purple-500 transition-colors font-medium">
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
this.showModal(`Log: ${result.log.task_name}`, modalContent);
|
||
|
||
// Stocker la sortie brute pour la copie
|
||
this.currentTaskLogRawOutput = parsed.output;
|
||
|
||
} catch (error) {
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
showStructuredPlaybookViewModal() {
|
||
if (!this.currentParsedOutput) return;
|
||
|
||
const metadata = this.currentStructuredPlaybookMetadata || {};
|
||
const structuredView = this.renderStructuredPlaybookView(this.currentParsedOutput, metadata);
|
||
const title = this.currentStructuredPlaybookTitle || 'Résultat Playbook';
|
||
const rawOutput = this.currentTaskLogRawOutput || '';
|
||
|
||
this.showModal(title, `
|
||
<div class="playbook-execution-viewer" style="max-width: 100%;">
|
||
${structuredView}
|
||
<div class="mt-4">
|
||
<button onclick="dashboard.closeModal()" class="w-full px-4 py-3 bg-purple-600 rounded-xl hover:bg-purple-500 transition-colors font-medium">
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
|
||
// Remplir la sortie brute formatée
|
||
setTimeout(() => {
|
||
const rawEl = document.getElementById('ansible-raw-output');
|
||
if (rawEl) {
|
||
rawEl.innerHTML = this.formatAnsibleOutput(rawOutput, true);
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
returnToStructuredPlaybookView() {
|
||
this.showStructuredPlaybookViewModal();
|
||
}
|
||
|
||
renderAdHocStructuredView(hostOutputs, metadata) {
|
||
/**
|
||
* Génère une vue structurée pour les commandes ad-hoc (similaire aux playbooks)
|
||
*/
|
||
const isSuccess = metadata.isSuccess;
|
||
const totalHosts = hostOutputs.length;
|
||
const successCount = hostOutputs.filter(h => h.status === 'changed' || h.status === 'success').length;
|
||
const failedCount = hostOutputs.filter(h => h.status === 'failed' || h.status === 'unreachable').length;
|
||
const successRate = totalHosts > 0 ? Math.round((successCount / totalHosts) * 100) : 0;
|
||
|
||
// Générer les cartes d'hôtes
|
||
const hostCardsHtml = hostOutputs.map(host => {
|
||
const isFailed = host.status === 'failed' || host.status === 'unreachable';
|
||
const hasChanges = host.status === 'changed';
|
||
|
||
let statusClass, statusIcon;
|
||
if (isFailed) {
|
||
statusClass = 'border-red-500/50 bg-red-900/20';
|
||
statusIcon = '<i class="fas fa-times-circle text-red-400"></i>';
|
||
} else if (hasChanges) {
|
||
statusClass = 'border-yellow-500/50 bg-yellow-900/20';
|
||
statusIcon = '<i class="fas fa-exchange-alt text-yellow-400"></i>';
|
||
} else {
|
||
statusClass = 'border-green-500/50 bg-green-900/20';
|
||
statusIcon = '<i class="fas fa-check-circle text-green-400"></i>';
|
||
}
|
||
|
||
const hostStatus = isFailed ? 'failed' : (hasChanges ? 'changed' : 'ok');
|
||
|
||
return `
|
||
<div class="host-card-item p-3 rounded-lg border ${statusClass} hover:scale-[1.02] transition-all cursor-pointer"
|
||
data-hostname="${this.escapeHtml(host.hostname)}"
|
||
data-status="${hostStatus}"
|
||
onclick="dashboard.showAdHocHostDetails('${this.escapeHtml(host.hostname)}')">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2">
|
||
${statusIcon}
|
||
<span class="font-medium text-white text-sm truncate max-w-[120px]" title="${this.escapeHtml(host.hostname)}">
|
||
${this.escapeHtml(host.hostname)}
|
||
</span>
|
||
</div>
|
||
<span class="text-xs px-2 py-0.5 rounded ${isFailed ? 'bg-red-900/50 text-red-300' : hasChanges ? 'bg-yellow-900/50 text-yellow-300' : 'bg-green-900/50 text-green-300'}">
|
||
${host.status.toUpperCase()}
|
||
</span>
|
||
</div>
|
||
<div class="text-xs text-gray-500 truncate" title="${this.escapeHtml(host.output.substring(0, 100))}">
|
||
${this.escapeHtml(host.output.substring(0, 60))}${host.output.length > 60 ? '...' : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="ansible-viewer" data-view-type="adhoc">
|
||
<!-- En-tête Contextuel -->
|
||
<div class="execution-header p-4 rounded-xl mb-4 ${isSuccess ? 'bg-gradient-to-r from-green-900/40 to-emerald-900/30 border border-green-700/50' : 'bg-gradient-to-r from-red-900/40 to-orange-900/30 border border-red-700/50'}">
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex items-center gap-4">
|
||
<div class="w-14 h-14 rounded-2xl flex items-center justify-center ${isSuccess ? 'bg-green-600/30' : 'bg-red-600/30'} backdrop-blur-sm">
|
||
<i class="fas fa-terminal text-2xl ${isSuccess ? 'text-green-400' : 'text-red-400'}"></i>
|
||
</div>
|
||
<div>
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<span class="px-2 py-0.5 text-xs font-medium rounded-full ${isSuccess ? 'bg-green-600/40 text-green-300' : 'bg-red-600/40 text-red-300'}">
|
||
Commande Ad-Hoc
|
||
</span>
|
||
</div>
|
||
<h3 class="text-xl font-bold text-white mb-1">${this.escapeHtml(metadata.taskName || 'Commande Ansible')}</h3>
|
||
<p class="text-sm text-gray-400">
|
||
<i class="fas fa-server mr-1"></i>${totalHosts} hôte(s)
|
||
<span class="mx-2">•</span>
|
||
<i class="fas fa-bullseye mr-1"></i>Cible: ${this.escapeHtml(metadata.target)}
|
||
<span class="mx-2">•</span>
|
||
<i class="fas fa-clock mr-1"></i>${metadata.duration}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-col items-end gap-2">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-3xl font-bold ${isSuccess ? 'text-green-400' : 'text-red-400'}">
|
||
${isSuccess ? '✓' : '✗'}
|
||
</span>
|
||
<span class="text-lg font-semibold ${isSuccess ? 'text-green-400' : 'text-red-400'}">
|
||
${isSuccess ? 'SUCCESS' : 'FAILED'}
|
||
</span>
|
||
</div>
|
||
<div class="text-xs text-gray-500">${metadata.date || ''}</div>
|
||
${metadata.returnCode !== undefined ? `
|
||
<div class="flex items-center gap-1.5 px-2 py-1 bg-gray-800/80 rounded text-xs">
|
||
<i class="fas fa-hashtag text-gray-400"></i>
|
||
<span class="text-white">Code: ${metadata.returnCode}</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Statistiques -->
|
||
<div class="grid grid-cols-4 gap-3 mb-4">
|
||
<div class="stat-card p-3 rounded-xl bg-green-900/20 border border-green-700/30">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<i class="fas fa-check text-green-400 text-xs"></i>
|
||
<span class="text-xs text-gray-400 uppercase">OK</span>
|
||
</div>
|
||
<div class="text-2xl font-bold text-green-400">${successCount}</div>
|
||
</div>
|
||
<div class="stat-card p-3 rounded-xl bg-yellow-900/20 border border-yellow-700/30">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<i class="fas fa-exchange-alt text-yellow-400 text-xs"></i>
|
||
<span class="text-xs text-gray-400 uppercase">Changed</span>
|
||
</div>
|
||
<div class="text-2xl font-bold text-yellow-400">${hostOutputs.filter(h => h.status === 'changed').length}</div>
|
||
</div>
|
||
<div class="stat-card p-3 rounded-xl bg-red-900/20 border border-red-700/30">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<i class="fas fa-times text-red-400 text-xs"></i>
|
||
<span class="text-xs text-gray-400 uppercase">Failed</span>
|
||
</div>
|
||
<div class="text-2xl font-bold text-red-400">${failedCount}</div>
|
||
</div>
|
||
<div class="stat-card p-3 rounded-xl bg-purple-900/20 border border-purple-700/30">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<i class="fas fa-percentage text-purple-400 text-xs"></i>
|
||
<span class="text-xs text-gray-400 uppercase">Success Rate</span>
|
||
</div>
|
||
<div class="text-2xl font-bold text-purple-400">${successRate}%</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- État des Hôtes -->
|
||
<div class="host-status-section mb-4">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h4 class="text-sm font-semibold text-gray-300 uppercase tracking-wide">
|
||
<i class="fas fa-server mr-2 text-purple-400"></i>État des Hôtes
|
||
</h4>
|
||
<div class="flex items-center gap-2">
|
||
<button type="button" onclick="dashboard.filterAdHocViewByStatus('all')"
|
||
class="av-filter-btn active px-2 py-1 text-xs rounded bg-purple-600/50 text-white" data-filter="all">
|
||
Tous
|
||
</button>
|
||
<button type="button" onclick="dashboard.filterAdHocViewByStatus('ok')"
|
||
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="ok">
|
||
<i class="fas fa-check text-green-400 mr-1"></i>OK
|
||
</button>
|
||
<button type="button" onclick="dashboard.filterAdHocViewByStatus('changed')"
|
||
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="changed">
|
||
<i class="fas fa-exchange-alt text-yellow-400 mr-1"></i>Changed
|
||
</button>
|
||
<button type="button" onclick="dashboard.filterAdHocViewByStatus('failed')"
|
||
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="failed">
|
||
<i class="fas fa-times text-red-400 mr-1"></i>Failed
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="adhoc-host-cards-grid grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||
${hostCardsHtml}
|
||
</div>
|
||
</div>
|
||
|
||
${metadata.error ? `
|
||
<!-- Section erreurs -->
|
||
<div class="mb-4">
|
||
<details class="group" open>
|
||
<summary class="flex items-center gap-2 p-3 bg-red-900/30 rounded-lg cursor-pointer hover:bg-red-900/40 transition-colors">
|
||
<i class="fas fa-exclamation-triangle text-red-400"></i>
|
||
<span class="text-sm font-medium text-red-300">Erreurs détectées</span>
|
||
<i class="fas fa-chevron-right text-xs text-red-500 transition-transform group-open:rotate-90 ml-auto"></i>
|
||
</summary>
|
||
<pre class="mt-2 text-xs bg-red-950/30 p-4 rounded-lg overflow-auto font-mono text-red-300 leading-relaxed border border-red-900/50" style="max-height: 150px;">${this.escapeHtml(metadata.error)}</pre>
|
||
</details>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
showAdHocHostDetails(hostname) {
|
||
const hostOutputs = this.currentTaskLogHostOutputs || [];
|
||
const host = hostOutputs.find(h => h.hostname === hostname);
|
||
if (!host) {
|
||
this.showNotification('Hôte non trouvé', 'error');
|
||
return;
|
||
}
|
||
|
||
const isFailed = host.status === 'failed' || host.status === 'unreachable';
|
||
const hasChanges = host.status === 'changed';
|
||
|
||
let statusClass, statusIcon, statusText;
|
||
if (isFailed) {
|
||
statusClass = 'bg-red-900/30 border-red-700/50';
|
||
statusIcon = '<i class="fas fa-times-circle text-red-400"></i>';
|
||
statusText = '<span class="text-red-400">FAILED</span>';
|
||
} else if (hasChanges) {
|
||
statusClass = 'bg-yellow-900/30 border-yellow-700/50';
|
||
statusIcon = '<i class="fas fa-exchange-alt text-yellow-400"></i>';
|
||
statusText = '<span class="text-yellow-400">CHANGED</span>';
|
||
} else {
|
||
statusClass = 'bg-green-900/30 border-green-700/50';
|
||
statusIcon = '<i class="fas fa-check-circle text-green-400"></i>';
|
||
statusText = '<span class="text-green-400">OK</span>';
|
||
}
|
||
|
||
const content = `
|
||
<div class="host-details-modal">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<button type="button" onclick="dashboard.returnToAdHocView()" class="inline-flex items-center gap-2 text-xs px-2 py-1 rounded bg-gray-800 text-gray-300 hover:bg-gray-700 transition-colors">
|
||
<i class="fas fa-arrow-left text-gray-400"></i>
|
||
<span>Retour au résumé</span>
|
||
</button>
|
||
<div class="text-xs text-gray-500 hidden sm:flex items-center gap-1">
|
||
<span class="text-gray-400">Résumé</span>
|
||
<span class="text-gray-600">›</span>
|
||
<span class="text-gray-200 truncate max-w-[180px]">${this.escapeHtml(hostname)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-3 mb-4 p-3 ${statusClass} rounded-lg border">
|
||
<div class="w-10 h-10 rounded-lg bg-purple-600/30 flex items-center justify-center">
|
||
<i class="fas fa-server text-purple-400"></i>
|
||
</div>
|
||
<div class="flex-1">
|
||
<h4 class="font-semibold text-white">${this.escapeHtml(hostname)}</h4>
|
||
<div class="text-xs text-gray-400">
|
||
Statut: ${statusText}
|
||
</div>
|
||
</div>
|
||
${statusIcon}
|
||
</div>
|
||
|
||
<div class="mb-4">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">
|
||
<i class="fas fa-terminal mr-1"></i>Sortie
|
||
</span>
|
||
<button onclick="dashboard.copyToClipboard(\`${this.escapeHtml(host.output).replace(/`/g, '\\`')}\`)" class="text-xs text-gray-400 hover:text-white transition-colors">
|
||
<i class="fas fa-copy mr-1"></i>Copier
|
||
</button>
|
||
</div>
|
||
<pre class="text-sm bg-black/60 p-4 rounded-lg overflow-auto font-mono text-gray-300 leading-relaxed border border-gray-800" style="max-height: 400px;">${this.formatAnsibleOutput(host.output, !isFailed)}</pre>
|
||
</div>
|
||
|
||
<button onclick="dashboard.closeModal()" class="w-full px-4 py-3 bg-purple-600 rounded-xl hover:bg-purple-500 transition-colors font-medium">
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
this.showModal(`Détails: ${hostname}`, content);
|
||
}
|
||
|
||
returnToAdHocView() {
|
||
if (!this.currentTaskLogHostOutputs || !this.currentAdHocMetadata) return;
|
||
|
||
const adHocView = this.renderAdHocStructuredView(this.currentTaskLogHostOutputs, this.currentAdHocMetadata);
|
||
const title = this.currentAdHocTitle || 'Résultat Ad-Hoc';
|
||
|
||
this.showModal(title, `
|
||
<div class="playbook-execution-viewer" style="max-width: 100%;">
|
||
${adHocView}
|
||
<div class="mt-4">
|
||
<button onclick="dashboard.closeModal()" class="w-full px-4 py-3 bg-purple-600 rounded-xl hover:bg-purple-500 transition-colors font-medium">
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
filterAdHocViewByStatus(status) {
|
||
const cards = document.querySelectorAll('.adhoc-host-cards-grid .host-card-item');
|
||
const buttons = document.querySelectorAll('.host-status-section .av-filter-btn');
|
||
|
||
buttons.forEach(btn => {
|
||
btn.classList.remove('active', 'bg-purple-600/50', 'text-white');
|
||
btn.classList.add('bg-gray-700/50', 'text-gray-400');
|
||
});
|
||
|
||
const activeBtn = document.querySelector(`.host-status-section .av-filter-btn[data-filter="${status}"]`);
|
||
if (activeBtn) {
|
||
activeBtn.classList.remove('bg-gray-700/50', 'text-gray-400');
|
||
activeBtn.classList.add('active', 'bg-purple-600/50', 'text-white');
|
||
}
|
||
|
||
cards.forEach(card => {
|
||
const cardStatus = card.dataset.status;
|
||
if (status === 'all' || cardStatus === status) {
|
||
card.style.display = '';
|
||
} else {
|
||
card.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
parseTaskLogMarkdown(content) {
|
||
// Parser le contenu markdown pour extraire les métadonnées
|
||
const result = {
|
||
taskName: '',
|
||
id: '',
|
||
target: '',
|
||
status: '',
|
||
progress: 0,
|
||
startTime: '',
|
||
endTime: '',
|
||
duration: '',
|
||
output: '',
|
||
error: '',
|
||
returnCode: undefined
|
||
};
|
||
|
||
// Extraire le nom de la tâche du titre
|
||
const titleMatch = content.match(/^#\s*[✅❌🔄⏳🚫❓]?\s*(.+)$/m);
|
||
if (titleMatch) result.taskName = titleMatch[1].trim();
|
||
|
||
// Extraire les valeurs de la table d'informations
|
||
const tablePatterns = {
|
||
id: /\|\s*\*\*ID\*\*\s*\|\s*`([^`]+)`/,
|
||
target: /\|\s*\*\*Cible\*\*\s*\|\s*`([^`]+)`/,
|
||
status: /\|\s*\*\*Statut\*\*\s*\|\s*(\w+)/,
|
||
progress: /\|\s*\*\*Progression\*\*\s*\|\s*(\d+)%/,
|
||
startTime: /\|\s*\*\*Début\*\*\s*\|\s*([^|]+)/,
|
||
endTime: /\|\s*\*\*Fin\*\*\s*\|\s*([^|]+)/,
|
||
duration: /\|\s*\*\*Durée\*\*\s*\|\s*([^|]+)/
|
||
};
|
||
|
||
for (const [key, pattern] of Object.entries(tablePatterns)) {
|
||
const match = content.match(pattern);
|
||
if (match) {
|
||
if (key === 'progress') {
|
||
result[key] = parseInt(match[1]);
|
||
} else {
|
||
result[key] = match[1].trim();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extraire la sortie
|
||
const outputMatch = content.match(/## Sortie\s*```([\s\S]*?)```/m);
|
||
if (outputMatch) {
|
||
result.output = outputMatch[1].trim();
|
||
|
||
// Essayer d'extraire le return code de la sortie
|
||
const rcMatch = result.output.match(/rc=(\d+)/);
|
||
if (rcMatch) {
|
||
result.returnCode = parseInt(rcMatch[1]);
|
||
}
|
||
}
|
||
|
||
// Extraire les erreurs
|
||
const errorMatch = content.match(/## Erreurs\s*```([\s\S]*?)```/m);
|
||
if (errorMatch) {
|
||
result.error = errorMatch[1].trim();
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// ===== PARSER ANSIBLE INTELLIGENT =====
|
||
|
||
parseAnsiblePlaybookOutput(output) {
|
||
/**
|
||
* Parser intelligent pour extraire la structure complète d'une exécution Ansible
|
||
* Retourne: { plays: [], hosts: {}, recap: {}, metadata: {} }
|
||
*/
|
||
const result = {
|
||
metadata: {
|
||
configFile: '',
|
||
playbookName: '',
|
||
executionTime: null,
|
||
totalDuration: 0
|
||
},
|
||
plays: [],
|
||
hosts: {},
|
||
recap: {},
|
||
stats: {
|
||
totalTasks: 0,
|
||
totalHosts: 0,
|
||
successRate: 0,
|
||
changedRate: 0
|
||
}
|
||
};
|
||
|
||
const lines = output.split('\n');
|
||
let currentPlay = null;
|
||
let currentTask = null;
|
||
let inRecap = false;
|
||
|
||
// Patterns de détection
|
||
const patterns = {
|
||
config: /^Using\s+(.+)\s+as config file$/,
|
||
play: /^PLAY\s+\[([^\]]+)\]\s*\*+$/,
|
||
task: /^TASK\s+\[([^\]]+)\]\s*\*+$/,
|
||
hostResult: /^(ok|changed|failed|unreachable|skipping|fatal):\s*\[([^\]]+)\]\s*(?:=>\s*)?(.*)$/i,
|
||
hostResultAlt: /^([\w\.\-]+)\s*\|\s*(SUCCESS|CHANGED|FAILED|UNREACHABLE)\s*(?:\|\s*rc=(\d+))?\s*>>?\s*$/i,
|
||
recap: /^PLAY RECAP\s*\*+$/,
|
||
recapLine: /^([\w\.\-]+)\s*:\s*ok=(\d+)\s+changed=(\d+)\s+unreachable=(\d+)\s+failed=(\d+)(?:\s+skipped=(\d+))?(?:\s+rescued=(\d+))?(?:\s+ignored=(\d+))?/
|
||
};
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i].trim();
|
||
if (!line) continue;
|
||
|
||
// Détecter le fichier de configuration
|
||
let match = line.match(patterns.config);
|
||
if (match) {
|
||
result.metadata.configFile = match[1];
|
||
continue;
|
||
}
|
||
|
||
// Détecter un PLAY
|
||
match = line.match(patterns.play);
|
||
if (match) {
|
||
currentPlay = {
|
||
name: match[1],
|
||
tasks: [],
|
||
startIndex: i
|
||
};
|
||
result.plays.push(currentPlay);
|
||
if (!result.metadata.playbookName) {
|
||
result.metadata.playbookName = match[1];
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Détecter une TASK
|
||
match = line.match(patterns.task);
|
||
if (match) {
|
||
currentTask = {
|
||
name: match[1],
|
||
hostResults: [],
|
||
startIndex: i
|
||
};
|
||
if (currentPlay) {
|
||
currentPlay.tasks.push(currentTask);
|
||
}
|
||
result.stats.totalTasks++;
|
||
continue;
|
||
}
|
||
|
||
// Détecter PLAY RECAP
|
||
if (patterns.recap.test(line)) {
|
||
inRecap = true;
|
||
continue;
|
||
}
|
||
|
||
// Parser les lignes de RECAP
|
||
if (inRecap) {
|
||
match = line.match(patterns.recapLine);
|
||
if (match) {
|
||
const hostname = match[1];
|
||
result.recap[hostname] = {
|
||
ok: parseInt(match[2]) || 0,
|
||
changed: parseInt(match[3]) || 0,
|
||
unreachable: parseInt(match[4]) || 0,
|
||
failed: parseInt(match[5]) || 0,
|
||
skipped: parseInt(match[6]) || 0,
|
||
rescued: parseInt(match[7]) || 0,
|
||
ignored: parseInt(match[8]) || 0
|
||
};
|
||
|
||
// Déterminer le statut global de l'hôte
|
||
const stats = result.recap[hostname];
|
||
if (stats.failed > 0 || stats.unreachable > 0) {
|
||
result.hosts[hostname] = { ...result.hosts[hostname], globalStatus: 'failed' };
|
||
} else if (stats.changed > 0) {
|
||
result.hosts[hostname] = { ...result.hosts[hostname], globalStatus: 'changed' };
|
||
} else {
|
||
result.hosts[hostname] = { ...result.hosts[hostname], globalStatus: 'ok' };
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Détecter les résultats par hôte (format standard)
|
||
match = line.match(patterns.hostResult);
|
||
if (match && currentTask) {
|
||
const status = match[1].toLowerCase();
|
||
const hostname = match[2];
|
||
let outputData = match[3] || '';
|
||
|
||
// Collecter les lignes suivantes si c'est un JSON multi-lignes
|
||
if (outputData.includes('{') && !outputData.includes('}')) {
|
||
let braceCount = (outputData.match(/{/g) || []).length - (outputData.match(/}/g) || []).length;
|
||
while (braceCount > 0 && i + 1 < lines.length) {
|
||
i++;
|
||
outputData += '\n' + lines[i];
|
||
braceCount += (lines[i].match(/{/g) || []).length - (lines[i].match(/}/g) || []).length;
|
||
}
|
||
}
|
||
|
||
const hostResult = {
|
||
hostname,
|
||
status,
|
||
output: outputData,
|
||
taskName: currentTask.name
|
||
};
|
||
|
||
// Parser le JSON si présent
|
||
try {
|
||
const jsonMatch = outputData.match(/=>\s*({[\s\S]*})/m) || outputData.match(/^({[\s\S]*})$/m);
|
||
if (jsonMatch) {
|
||
hostResult.parsedOutput = JSON.parse(jsonMatch[1]);
|
||
}
|
||
} catch (e) {
|
||
// Ignorer les erreurs de parsing JSON
|
||
}
|
||
|
||
currentTask.hostResults.push(hostResult);
|
||
|
||
// Enregistrer l'hôte s'il n'existe pas
|
||
if (!result.hosts[hostname]) {
|
||
result.hosts[hostname] = { taskResults: [], globalStatus: 'unknown' };
|
||
}
|
||
result.hosts[hostname].taskResults.push(hostResult);
|
||
continue;
|
||
}
|
||
|
||
// Format alternatif (hostname | STATUS | rc=X >>)
|
||
match = line.match(patterns.hostResultAlt);
|
||
if (match && currentTask) {
|
||
const hostname = match[1];
|
||
const status = match[2].toLowerCase();
|
||
const rc = match[3] ? parseInt(match[3]) : 0;
|
||
|
||
// Collecter la sortie sur les lignes suivantes
|
||
let outputLines = [];
|
||
while (i + 1 < lines.length) {
|
||
const nextLine = lines[i + 1];
|
||
if (nextLine.match(patterns.hostResultAlt) ||
|
||
nextLine.match(patterns.task) ||
|
||
nextLine.match(patterns.play) ||
|
||
nextLine.match(patterns.recap)) {
|
||
break;
|
||
}
|
||
i++;
|
||
outputLines.push(lines[i]);
|
||
}
|
||
|
||
const hostResult = {
|
||
hostname,
|
||
status,
|
||
returnCode: rc,
|
||
output: outputLines.join('\n'),
|
||
taskName: currentTask.name
|
||
};
|
||
|
||
currentTask.hostResults.push(hostResult);
|
||
|
||
if (!result.hosts[hostname]) {
|
||
result.hosts[hostname] = { taskResults: [], globalStatus: 'unknown' };
|
||
}
|
||
result.hosts[hostname].taskResults.push(hostResult);
|
||
}
|
||
}
|
||
|
||
// Calculer les statistiques
|
||
result.stats.totalHosts = Object.keys(result.hosts).length;
|
||
|
||
if (Object.keys(result.recap).length > 0) {
|
||
let totalOk = 0, totalChanged = 0, totalFailed = 0, totalTasks = 0;
|
||
for (const stats of Object.values(result.recap)) {
|
||
totalOk += stats.ok;
|
||
totalChanged += stats.changed;
|
||
totalFailed += stats.failed + stats.unreachable;
|
||
totalTasks += stats.ok + stats.changed + stats.failed + stats.unreachable + stats.skipped;
|
||
}
|
||
result.stats.successRate = totalTasks > 0 ? Math.round(((totalOk + totalChanged) / totalTasks) * 100) : 0;
|
||
result.stats.changedRate = totalTasks > 0 ? Math.round((totalChanged / totalTasks) * 100) : 0;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
detectPlaybookType(parsedOutput) {
|
||
/**
|
||
* Détecte automatiquement le type de playbook basé sur le contenu
|
||
*/
|
||
const taskNames = parsedOutput.plays.flatMap(p => p.tasks.map(t => t.name.toLowerCase()));
|
||
const playName = (parsedOutput.metadata.playbookName || '').toLowerCase();
|
||
|
||
// Patterns de détection
|
||
const patterns = {
|
||
healthCheck: ['health', 'check', 'ping', 'uptime', 'status', 'monitor', 'disk', 'memory', 'cpu'],
|
||
deployment: ['deploy', 'release', 'version', 'rollout', 'install', 'upgrade'],
|
||
configuration: ['config', 'configure', 'setup', 'settings', 'template'],
|
||
backup: ['backup', 'restore', 'snapshot', 'archive'],
|
||
security: ['security', 'firewall', 'ssl', 'certificate', 'password', 'key'],
|
||
maintenance: ['clean', 'prune', 'update', 'patch', 'restart', 'reboot']
|
||
};
|
||
|
||
for (const [type, keywords] of Object.entries(patterns)) {
|
||
const matchScore = keywords.filter(kw =>
|
||
playName.includes(kw) || taskNames.some(t => t.includes(kw))
|
||
).length;
|
||
if (matchScore >= 2 || playName.includes(type.toLowerCase())) {
|
||
return type;
|
||
}
|
||
}
|
||
|
||
return 'general';
|
||
}
|
||
|
||
renderStructuredPlaybookView(parsedOutput, metadata = {}) {
|
||
/**
|
||
* Génère le HTML structuré pour l'affichage du playbook
|
||
*/
|
||
const playbookType = this.detectPlaybookType(parsedOutput);
|
||
const isSuccess = !Object.values(parsedOutput.recap).some(r => r.failed > 0 || r.unreachable > 0);
|
||
const hasChanges = Object.values(parsedOutput.recap).some(r => r.changed > 0);
|
||
|
||
// Icônes par type de playbook
|
||
const typeIcons = {
|
||
healthCheck: 'fa-heartbeat',
|
||
deployment: 'fa-rocket',
|
||
configuration: 'fa-cogs',
|
||
backup: 'fa-database',
|
||
security: 'fa-shield-alt',
|
||
maintenance: 'fa-tools',
|
||
general: 'fa-play-circle'
|
||
};
|
||
|
||
const typeLabels = {
|
||
healthCheck: 'Health Check',
|
||
deployment: 'Déploiement',
|
||
configuration: 'Configuration',
|
||
backup: 'Backup',
|
||
security: 'Sécurité',
|
||
maintenance: 'Maintenance',
|
||
general: 'Playbook'
|
||
};
|
||
|
||
// Générer les cartes d'hôtes
|
||
const hostCardsHtml = this.renderHostStatusCards(parsedOutput);
|
||
|
||
// Générer l'arborescence des tâches
|
||
const taskTreeHtml = this.renderTaskHierarchy(parsedOutput);
|
||
|
||
// Générer les statistiques
|
||
const statsHtml = this.renderExecutionStats(parsedOutput);
|
||
|
||
return `
|
||
<div class="ansible-viewer" data-playbook-type="${playbookType}">
|
||
<!-- En-tête Contextuel -->
|
||
<div class="execution-header p-4 rounded-xl mb-4 ${isSuccess ? 'bg-gradient-to-r from-green-900/40 to-emerald-900/30 border border-green-700/50' : 'bg-gradient-to-r from-red-900/40 to-orange-900/30 border border-red-700/50'}">
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex items-center gap-4">
|
||
<div class="w-14 h-14 rounded-2xl flex items-center justify-center ${isSuccess ? 'bg-green-600/30' : 'bg-red-600/30'} backdrop-blur-sm">
|
||
<i class="fas ${typeIcons[playbookType]} text-2xl ${isSuccess ? 'text-green-400' : 'text-red-400'}"></i>
|
||
</div>
|
||
<div>
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<span class="px-2 py-0.5 text-xs font-medium rounded-full ${isSuccess ? 'bg-green-600/40 text-green-300' : 'bg-red-600/40 text-red-300'}">
|
||
${typeLabels[playbookType]}
|
||
</span>
|
||
${hasChanges ? '<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-600/40 text-yellow-300">Changes Applied</span>' : ''}
|
||
</div>
|
||
<h3 class="text-xl font-bold text-white mb-1">${this.escapeHtml(parsedOutput.metadata.playbookName || 'Ansible Playbook')}</h3>
|
||
<p class="text-sm text-gray-400">
|
||
<i class="fas fa-server mr-1"></i>${parsedOutput.stats.totalHosts} hôte(s)
|
||
<span class="mx-2">•</span>
|
||
<i class="fas fa-tasks mr-1"></i>${parsedOutput.stats.totalTasks} tâche(s)
|
||
<span class="mx-2">•</span>
|
||
<i class="fas fa-clock mr-1"></i>${metadata.duration || 'N/A'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-col items-end gap-2">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-3xl font-bold ${isSuccess ? 'text-green-400' : 'text-red-400'}">
|
||
${isSuccess ? '✓' : '✗'}
|
||
</span>
|
||
<span class="text-lg font-semibold ${isSuccess ? 'text-green-400' : 'text-red-400'}">
|
||
${isSuccess ? 'SUCCESS' : 'FAILED'}
|
||
</span>
|
||
</div>
|
||
<div class="text-xs text-gray-500">${metadata.date || ''}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Statistiques Visuelles -->
|
||
${statsHtml}
|
||
|
||
<!-- Cartes d'état des hôtes -->
|
||
<div class="mb-4">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h4 class="text-sm font-semibold text-gray-300 uppercase tracking-wide">
|
||
<i class="fas fa-server mr-2 text-purple-400"></i>État des Hôtes
|
||
</h4>
|
||
<div class="flex items-center gap-2">
|
||
<button type="button" onclick="dashboard.filterAnsibleViewByStatus('all')"
|
||
class="av-filter-btn active px-2 py-1 text-xs rounded bg-gray-700 text-gray-300 hover:bg-gray-600" data-filter="all">
|
||
Tous
|
||
</button>
|
||
<button type="button" onclick="dashboard.filterAnsibleViewByStatus('ok')"
|
||
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="ok">
|
||
<i class="fas fa-check text-green-400 mr-1"></i>OK
|
||
</button>
|
||
<button type="button" onclick="dashboard.filterAnsibleViewByStatus('changed')"
|
||
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="changed">
|
||
<i class="fas fa-exchange-alt text-yellow-400 mr-1"></i>Changed
|
||
</button>
|
||
<button type="button" onclick="dashboard.filterAnsibleViewByStatus('failed')"
|
||
class="av-filter-btn px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600" data-filter="failed">
|
||
<i class="fas fa-times text-red-400 mr-1"></i>Failed
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="host-cards-grid grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||
${hostCardsHtml}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Arborescence des Tâches -->
|
||
<div class="task-hierarchy-section">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h4 class="text-sm font-semibold text-gray-300 uppercase tracking-wide">
|
||
<i class="fas fa-sitemap mr-2 text-blue-400"></i>Hiérarchie des Tâches
|
||
</h4>
|
||
<div class="flex items-center gap-2">
|
||
<button type="button" onclick="dashboard.expandAllTasks()" class="px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600 hover:text-white">
|
||
<i class="fas fa-expand-alt mr-1"></i>Tout déplier
|
||
</button>
|
||
<button type="button" onclick="dashboard.collapseAllTasks()" class="px-2 py-1 text-xs rounded bg-gray-700/50 text-gray-400 hover:bg-gray-600 hover:text-white">
|
||
<i class="fas fa-compress-alt mr-1"></i>Tout replier
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="task-tree bg-gray-900/50 rounded-xl border border-gray-800">
|
||
${taskTreeHtml}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Vue brute (repliable) -->
|
||
<details class="mt-4 group">
|
||
<summary class="flex items-center gap-2 p-3 bg-gray-800/50 rounded-lg cursor-pointer hover:bg-gray-800 transition-colors">
|
||
<i class="fas fa-chevron-right text-xs text-gray-500 transition-transform group-open:rotate-90"></i>
|
||
<span class="text-sm text-gray-400">Afficher la sortie brute</span>
|
||
</summary>
|
||
<pre id="ansible-raw-output" class="mt-2 text-xs bg-black/60 p-4 rounded-lg overflow-auto font-mono text-gray-400 leading-relaxed border border-gray-800" style="max-height: 300px;"></pre>
|
||
</details>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
renderHostStatusCards(parsedOutput) {
|
||
const hosts = Object.entries(parsedOutput.recap);
|
||
if (hosts.length === 0) {
|
||
return '<div class="text-gray-500 text-sm p-4">Aucun hôte détecté</div>';
|
||
}
|
||
|
||
return hosts.map(([hostname, stats]) => {
|
||
const total = stats.ok + stats.changed + stats.failed + stats.unreachable + stats.skipped;
|
||
const successPercent = total > 0 ? Math.round(((stats.ok + stats.changed) / total) * 100) : 0;
|
||
const isFailed = stats.failed > 0 || stats.unreachable > 0;
|
||
const hasChanges = stats.changed > 0;
|
||
|
||
let statusClass, statusIcon, statusBg;
|
||
if (isFailed) {
|
||
statusClass = 'border-red-500/50 bg-red-900/20';
|
||
statusIcon = '<i class="fas fa-times-circle text-red-400"></i>';
|
||
statusBg = 'bg-red-500';
|
||
} else if (hasChanges) {
|
||
statusClass = 'border-yellow-500/50 bg-yellow-900/20';
|
||
statusIcon = '<i class="fas fa-exchange-alt text-yellow-400"></i>';
|
||
statusBg = 'bg-yellow-500';
|
||
} else {
|
||
statusClass = 'border-green-500/50 bg-green-900/20';
|
||
statusIcon = '<i class="fas fa-check-circle text-green-400"></i>';
|
||
statusBg = 'bg-green-500';
|
||
}
|
||
|
||
const hostStatus = isFailed ? 'failed' : (hasChanges ? 'changed' : 'ok');
|
||
|
||
return `
|
||
<div class="host-card-item p-3 rounded-lg border ${statusClass} hover:scale-[1.02] transition-all cursor-pointer"
|
||
data-hostname="${this.escapeHtml(hostname)}"
|
||
data-status="${hostStatus}"
|
||
onclick="dashboard.showHostDetails('${this.escapeHtml(hostname)}')">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2">
|
||
${statusIcon}
|
||
<span class="font-medium text-white text-sm truncate max-w-[120px]" title="${this.escapeHtml(hostname)}">
|
||
${this.escapeHtml(hostname)}
|
||
</span>
|
||
</div>
|
||
<span class="text-xs ${isFailed ? 'text-red-400' : hasChanges ? 'text-yellow-400' : 'text-green-400'} font-semibold">
|
||
${successPercent}%
|
||
</span>
|
||
</div>
|
||
<div class="flex gap-1 h-1.5 rounded-full overflow-hidden bg-gray-800">
|
||
${stats.ok > 0 ? `<div class="bg-green-500" style="width: ${(stats.ok/total)*100}%" title="OK: ${stats.ok}"></div>` : ''}
|
||
${stats.changed > 0 ? `<div class="bg-yellow-500" style="width: ${(stats.changed/total)*100}%" title="Changed: ${stats.changed}"></div>` : ''}
|
||
${stats.skipped > 0 ? `<div class="bg-gray-500" style="width: ${(stats.skipped/total)*100}%" title="Skipped: ${stats.skipped}"></div>` : ''}
|
||
${stats.failed > 0 ? `<div class="bg-red-500" style="width: ${(stats.failed/total)*100}%" title="Failed: ${stats.failed}"></div>` : ''}
|
||
${stats.unreachable > 0 ? `<div class="bg-orange-500" style="width: ${(stats.unreachable/total)*100}%" title="Unreachable: ${stats.unreachable}"></div>` : ''}
|
||
</div>
|
||
<div class="flex justify-between mt-2 text-[10px] text-gray-500">
|
||
<span><span class="text-green-400">${stats.ok}</span> ok</span>
|
||
<span><span class="text-yellow-400">${stats.changed}</span> chg</span>
|
||
<span><span class="text-red-400">${stats.failed}</span> fail</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
renderTaskHierarchy(parsedOutput) {
|
||
if (parsedOutput.plays.length === 0) {
|
||
return '<div class="text-gray-500 text-sm p-4">Aucune tâche détectée</div>';
|
||
}
|
||
|
||
return parsedOutput.plays.map((play, playIndex) => {
|
||
const playTasks = play.tasks;
|
||
const allTasksSuccess = playTasks.every(t =>
|
||
t.hostResults.every(r => r.status === 'ok' || r.status === 'changed' || r.status === 'skipping')
|
||
);
|
||
const hasFailedTasks = playTasks.some(t =>
|
||
t.hostResults.some(r => r.status === 'failed' || r.status === 'fatal' || r.status === 'unreachable')
|
||
);
|
||
|
||
const playStatusIcon = hasFailedTasks
|
||
? '<i class="fas fa-times-circle text-red-400"></i>'
|
||
: '<i class="fas fa-check-circle text-green-400"></i>';
|
||
|
||
const tasksHtml = playTasks.map((task, taskIndex) => {
|
||
const hasFailures = task.hostResults.some(r => r.status === 'failed' || r.status === 'fatal' || r.status === 'unreachable');
|
||
const hasChanges = task.hostResults.some(r => r.status === 'changed');
|
||
const allSkipped = task.hostResults.every(r => r.status === 'skipping' || r.status === 'skipped');
|
||
|
||
let taskIcon, taskColor;
|
||
if (hasFailures) {
|
||
taskIcon = 'fa-times-circle';
|
||
taskColor = 'text-red-400';
|
||
} else if (hasChanges) {
|
||
taskIcon = 'fa-exchange-alt';
|
||
taskColor = 'text-yellow-400';
|
||
} else if (allSkipped) {
|
||
taskIcon = 'fa-forward';
|
||
taskColor = 'text-gray-500';
|
||
} else {
|
||
taskIcon = 'fa-check-circle';
|
||
taskColor = 'text-green-400';
|
||
}
|
||
|
||
const hostResultsHtml = task.hostResults.map(result => {
|
||
let resultIcon, resultColor, resultBg;
|
||
switch(result.status) {
|
||
case 'ok':
|
||
resultIcon = 'fa-check'; resultColor = 'text-green-400'; resultBg = 'bg-green-900/30';
|
||
break;
|
||
case 'changed':
|
||
resultIcon = 'fa-exchange-alt'; resultColor = 'text-yellow-400'; resultBg = 'bg-yellow-900/30';
|
||
break;
|
||
case 'failed':
|
||
case 'fatal':
|
||
resultIcon = 'fa-times'; resultColor = 'text-red-400'; resultBg = 'bg-red-900/30';
|
||
break;
|
||
case 'unreachable':
|
||
resultIcon = 'fa-unlink'; resultColor = 'text-orange-400'; resultBg = 'bg-orange-900/30';
|
||
break;
|
||
case 'skipping':
|
||
case 'skipped':
|
||
resultIcon = 'fa-forward'; resultColor = 'text-gray-500'; resultBg = 'bg-gray-800/50';
|
||
break;
|
||
default:
|
||
resultIcon = 'fa-question'; resultColor = 'text-gray-400'; resultBg = 'bg-gray-800/50';
|
||
}
|
||
|
||
// Extraire les données importantes de l'output
|
||
let outputPreview = '';
|
||
if (result.parsedOutput) {
|
||
const po = result.parsedOutput;
|
||
if (po.msg) outputPreview = po.msg;
|
||
else if (po.stdout) outputPreview = po.stdout.substring(0, 100);
|
||
else if (po.cmd) outputPreview = Array.isArray(po.cmd) ? po.cmd.join(' ') : po.cmd;
|
||
}
|
||
|
||
return `
|
||
<div class="host-result flex items-center justify-between py-1.5 px-3 ${resultBg} rounded text-xs"
|
||
data-hostname="${this.escapeHtml(result.hostname)}">
|
||
<div class="flex items-center gap-2">
|
||
<i class="fas ${resultIcon} ${resultColor} text-[10px]"></i>
|
||
<span class="text-gray-300 font-medium">${this.escapeHtml(result.hostname)}</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
${outputPreview ? `<span class="text-gray-500 truncate max-w-[200px]" title="${this.escapeHtml(outputPreview)}">${this.escapeHtml(outputPreview.substring(0, 50))}${outputPreview.length > 50 ? '...' : ''}</span>` : ''}
|
||
<span class="px-1.5 py-0.5 rounded ${resultBg} ${resultColor} text-[10px] uppercase font-semibold">
|
||
${result.status}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
return `
|
||
<details class="task-item border-b border-gray-800 last:border-b-0" data-task-index="${taskIndex}">
|
||
<summary class="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-800/50 transition-colors">
|
||
<div class="flex items-center gap-3">
|
||
<i class="fas fa-chevron-right text-xs text-gray-600 transition-transform"></i>
|
||
<i class="fas ${taskIcon} ${taskColor}"></i>
|
||
<span class="text-sm text-gray-200">${this.escapeHtml(task.name)}</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-xs text-gray-500">${task.hostResults.length} hôte(s)</span>
|
||
<div class="flex -space-x-1">
|
||
${task.hostResults.slice(0, 5).map(r => {
|
||
const dotColor = r.status === 'ok' ? 'bg-green-500' :
|
||
r.status === 'changed' ? 'bg-yellow-500' :
|
||
r.status === 'failed' || r.status === 'fatal' ? 'bg-red-500' : 'bg-gray-500';
|
||
return `<div class="w-2 h-2 rounded-full ${dotColor} border border-gray-900"></div>`;
|
||
}).join('')}
|
||
${task.hostResults.length > 5 ? `<span class="text-[10px] text-gray-500 ml-1">+${task.hostResults.length - 5}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
</summary>
|
||
<div class="task-details pl-8 pr-3 pb-3 space-y-1">
|
||
${hostResultsHtml}
|
||
</div>
|
||
</details>
|
||
`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="play-section" data-play-index="${playIndex}">
|
||
<div class="play-header flex items-center gap-3 p-3 bg-gray-800/80 border-b border-gray-700">
|
||
${playStatusIcon}
|
||
<span class="text-sm font-semibold text-white">PLAY [${this.escapeHtml(play.name)}]</span>
|
||
<span class="text-xs text-gray-500">${playTasks.length} tâche(s)</span>
|
||
</div>
|
||
<div class="play-tasks">
|
||
${tasksHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
renderExecutionStats(parsedOutput) {
|
||
const recap = parsedOutput.recap;
|
||
const hosts = Object.keys(recap);
|
||
|
||
if (hosts.length === 0) return '';
|
||
|
||
let totalOk = 0, totalChanged = 0, totalFailed = 0, totalSkipped = 0, totalUnreachable = 0;
|
||
for (const stats of Object.values(recap)) {
|
||
totalOk += stats.ok;
|
||
totalChanged += stats.changed;
|
||
totalFailed += stats.failed;
|
||
totalSkipped += stats.skipped;
|
||
totalUnreachable += stats.unreachable;
|
||
}
|
||
|
||
const total = totalOk + totalChanged + totalFailed + totalSkipped + totalUnreachable;
|
||
const successRate = total > 0 ? Math.round(((totalOk + totalChanged) / total) * 100) : 0;
|
||
|
||
return `
|
||
<div class="execution-stats grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
|
||
<div class="stat-card p-3 bg-green-900/20 border border-green-700/30 rounded-lg">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<i class="fas fa-check text-green-400"></i>
|
||
<span class="text-xs text-gray-400 uppercase">OK</span>
|
||
</div>
|
||
<div class="text-2xl font-bold text-green-400">${totalOk}</div>
|
||
</div>
|
||
<div class="stat-card p-3 bg-yellow-900/20 border border-yellow-700/30 rounded-lg">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<i class="fas fa-exchange-alt text-yellow-400"></i>
|
||
<span class="text-xs text-gray-400 uppercase">Changed</span>
|
||
</div>
|
||
<div class="text-2xl font-bold text-yellow-400">${totalChanged}</div>
|
||
</div>
|
||
<div class="stat-card p-3 bg-red-900/20 border border-red-700/30 rounded-lg">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<i class="fas fa-times text-red-400"></i>
|
||
<span class="text-xs text-gray-400 uppercase">Failed</span>
|
||
</div>
|
||
<div class="text-2xl font-bold text-red-400">${totalFailed}</div>
|
||
</div>
|
||
<div class="stat-card p-3 bg-purple-900/20 border border-purple-700/30 rounded-lg">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<i class="fas fa-percentage text-purple-400"></i>
|
||
<span class="text-xs text-gray-400 uppercase">Success Rate</span>
|
||
</div>
|
||
<div class="text-2xl font-bold text-purple-400">${successRate}%</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Méthodes d'interaction pour la vue structurée
|
||
filterAnsibleViewByStatus(status) {
|
||
document.querySelectorAll('.av-filter-btn').forEach(btn => {
|
||
btn.classList.remove('active', 'bg-gray-700', 'text-gray-300');
|
||
btn.classList.add('bg-gray-700/50', 'text-gray-400');
|
||
});
|
||
document.querySelector(`.av-filter-btn[data-filter="${status}"]`)?.classList.add('active', 'bg-gray-700', 'text-gray-300');
|
||
document.querySelector(`.av-filter-btn[data-filter="${status}"]`)?.classList.remove('bg-gray-700/50', 'text-gray-400');
|
||
|
||
document.querySelectorAll('.host-card-item').forEach(card => {
|
||
if (status === 'all' || card.dataset.status === status) {
|
||
card.style.display = '';
|
||
} else {
|
||
card.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
expandAllTasks() {
|
||
document.querySelectorAll('.task-item').forEach(item => {
|
||
item.setAttribute('open', 'open');
|
||
});
|
||
}
|
||
|
||
collapseAllTasks() {
|
||
document.querySelectorAll('.task-item').forEach(item => {
|
||
item.removeAttribute('open');
|
||
});
|
||
}
|
||
|
||
showHostDetails(hostname) {
|
||
if (!this.currentParsedOutput || !this.currentParsedOutput.hosts[hostname]) return;
|
||
|
||
const hostData = this.currentParsedOutput.hosts[hostname];
|
||
const recapData = this.currentParsedOutput.recap[hostname] || {};
|
||
|
||
const tasksHtml = hostData.taskResults.map(result => {
|
||
let statusIcon, statusColor;
|
||
switch(result.status) {
|
||
case 'ok': statusIcon = 'fa-check'; statusColor = 'text-green-400'; break;
|
||
case 'changed': statusIcon = 'fa-exchange-alt'; statusColor = 'text-yellow-400'; break;
|
||
case 'failed': case 'fatal': statusIcon = 'fa-times'; statusColor = 'text-red-400'; break;
|
||
default: statusIcon = 'fa-minus'; statusColor = 'text-gray-400';
|
||
}
|
||
|
||
return `
|
||
<div class="task-result p-3 bg-gray-800/50 rounded-lg mb-2">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2">
|
||
<i class="fas ${statusIcon} ${statusColor}"></i>
|
||
<span class="text-sm text-white font-medium">${this.escapeHtml(result.taskName)}</span>
|
||
</div>
|
||
<span class="px-2 py-0.5 text-xs rounded ${result.status === 'ok' ? 'bg-green-900/50 text-green-400' : result.status === 'changed' ? 'bg-yellow-900/50 text-yellow-400' : 'bg-red-900/50 text-red-400'}">
|
||
${result.status.toUpperCase()}
|
||
</span>
|
||
</div>
|
||
${result.output ? `
|
||
<pre class="text-xs text-gray-400 bg-black/30 p-2 rounded overflow-auto max-h-32 font-mono">${this.escapeHtml(result.output)}</pre>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
const content = `
|
||
<div class="host-details-modal">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<button type="button" onclick="dashboard.returnToStructuredPlaybookView()" class="inline-flex items-center gap-2 text-xs px-2 py-1 rounded bg-gray-800 text-gray-300 hover:bg-gray-700 transition-colors">
|
||
<i class="fas fa-arrow-left text-gray-400"></i>
|
||
<span>Retour au résumé</span>
|
||
</button>
|
||
<div class="text-xs text-gray-500 hidden sm:flex items-center gap-1">
|
||
<span class="text-gray-400">Résumé</span>
|
||
<span class="text-gray-600">›</span>
|
||
<span class="text-gray-200 truncate max-w-[180px]">${this.escapeHtml(hostname)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-3 mb-4 p-3 bg-gray-800 rounded-lg">
|
||
<div class="w-10 h-10 rounded-lg bg-purple-600/30 flex items-center justify-center">
|
||
<i class="fas fa-server text-purple-400"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-semibold text-white">${this.escapeHtml(hostname)}</h4>
|
||
<div class="text-xs text-gray-400">
|
||
<span class="text-green-400">${recapData.ok || 0} ok</span> •
|
||
<span class="text-yellow-400">${recapData.changed || 0} changed</span> •
|
||
<span class="text-red-400">${recapData.failed || 0} failed</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="tasks-list max-h-96 overflow-auto">
|
||
${tasksHtml}
|
||
</div>
|
||
<button onclick="dashboard.closeModal()" class="w-full mt-4 px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors">
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
this.showModal(`Détails: ${hostname}`, content);
|
||
}
|
||
|
||
switchTaskLogHostTab(index) {
|
||
if (!this.currentTaskLogHostOutputs || !this.currentTaskLogHostOutputs[index]) return;
|
||
|
||
const hostOutput = this.currentTaskLogHostOutputs[index];
|
||
const outputPre = document.getElementById('tasklog-output');
|
||
const tabs = document.querySelectorAll('.tasklog-host-tab');
|
||
|
||
// Mettre à jour l'onglet actif
|
||
tabs.forEach((tab, i) => {
|
||
if (i === index) {
|
||
const host = this.currentTaskLogHostOutputs[i];
|
||
const statusColor = (host.status === 'changed' || host.status === 'success')
|
||
? 'bg-green-600/80 border-green-500'
|
||
: (host.status === 'failed' || host.status === 'unreachable')
|
||
? 'bg-red-600/80 border-red-500'
|
||
: 'bg-gray-600/80 border-gray-500';
|
||
tab.className = `tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border ${statusColor} text-white`;
|
||
} else {
|
||
tab.className = 'tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border bg-gray-800 text-gray-400 hover:text-white border-gray-700';
|
||
}
|
||
});
|
||
|
||
// Afficher le contenu
|
||
if (outputPre) {
|
||
outputPre.innerHTML = this.formatAnsibleOutput(hostOutput.output, hostOutput.status === 'changed' || hostOutput.status === 'success');
|
||
}
|
||
}
|
||
|
||
showAllTaskLogHostsOutput() {
|
||
if (!this.currentTaskLogHostOutputs) return;
|
||
|
||
const outputPre = document.getElementById('tasklog-output');
|
||
if (outputPre) {
|
||
const allOutput = this.currentTaskLogHostOutputs.map(h => h.output).join('\n\n');
|
||
outputPre.innerHTML = this.formatAnsibleOutput(allOutput, true);
|
||
}
|
||
|
||
// Désélectionner tous les onglets
|
||
document.querySelectorAll('.tasklog-host-tab').forEach(tab => {
|
||
tab.className = 'tasklog-host-tab flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border bg-gray-800 text-gray-400 hover:text-white border-gray-700';
|
||
});
|
||
}
|
||
|
||
copyTaskLogOutput() {
|
||
const text = this.currentTaskLogRawOutput || '';
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
this.showNotification('Sortie copiée dans le presse-papiers', 'success');
|
||
}).catch(() => {
|
||
this.showNotification('Erreur lors de la copie', 'error');
|
||
});
|
||
}
|
||
|
||
downloadTaskLog(path) {
|
||
// Créer un lien pour télécharger le fichier
|
||
this.showNotification('Fonctionnalité de téléchargement à implémenter', 'info');
|
||
}
|
||
|
||
async deleteTaskLog(logId, filename) {
|
||
if (!confirm(`Supprimer le log "${filename}" ? Cette action est définitive.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await this.apiCall(`/api/tasks/logs/${logId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
this.showNotification('Log supprimé', 'success');
|
||
|
||
// Recharger la liste des logs avec les filtres courants
|
||
await this.loadTaskLogsWithFilters();
|
||
} catch (error) {
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// ===== FILTRAGE DES TÂCHES =====
|
||
|
||
filterTasksByStatus(status) {
|
||
this.currentStatusFilter = status;
|
||
this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination
|
||
|
||
// Mettre à jour l'apparence des boutons
|
||
document.querySelectorAll('.task-filter-btn').forEach(btn => {
|
||
btn.classList.remove('active', 'bg-purple-600', 'bg-blue-600', 'bg-green-600', 'bg-red-600');
|
||
btn.classList.add('bg-gray-700');
|
||
});
|
||
|
||
const activeBtn = document.querySelector(`.task-filter-btn[data-status="${status}"]`);
|
||
if (activeBtn) {
|
||
activeBtn.classList.remove('bg-gray-700');
|
||
const colorMap = {
|
||
'all': 'bg-purple-600',
|
||
'running': 'bg-blue-600',
|
||
'completed': 'bg-green-600',
|
||
'failed': 'bg-red-600'
|
||
};
|
||
activeBtn.classList.add('active', colorMap[status] || 'bg-purple-600');
|
||
}
|
||
|
||
// Recharger les logs avec le filtre
|
||
this.loadTaskLogsWithFilters();
|
||
}
|
||
|
||
filterTasksByCategory(category) {
|
||
this.currentCategoryFilter = category;
|
||
this.currentSubcategoryFilter = 'all'; // Reset subcategory when category changes
|
||
this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination
|
||
this.loadTaskLogsWithFilters();
|
||
}
|
||
|
||
filterTasksBySubcategory(subcategory) {
|
||
this.currentSubcategoryFilter = subcategory;
|
||
this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination
|
||
this.loadTaskLogsWithFilters();
|
||
}
|
||
|
||
filterTasksByTarget(target) {
|
||
this.currentTargetFilter = target;
|
||
this.tasksDisplayedCount = this.tasksPerPage; // Reset pagination
|
||
this.loadTaskLogsWithFilters();
|
||
}
|
||
|
||
// Fonctions pour effacer les filtres individuellement
|
||
clearTargetFilter() {
|
||
this.currentTargetFilter = 'all';
|
||
this.tasksDisplayedCount = this.tasksPerPage;
|
||
this.loadTaskLogsWithFilters();
|
||
this.showNotification('Filtre cible effacé', 'info');
|
||
}
|
||
|
||
clearCategoryFilter() {
|
||
this.currentCategoryFilter = 'all';
|
||
this.currentSubcategoryFilter = 'all';
|
||
this.tasksDisplayedCount = this.tasksPerPage;
|
||
this.loadTaskLogsWithFilters();
|
||
this.showNotification('Filtre catégorie effacé', 'info');
|
||
}
|
||
|
||
clearAllTaskFilters() {
|
||
this.currentTargetFilter = 'all';
|
||
this.currentCategoryFilter = 'all';
|
||
this.currentSubcategoryFilter = 'all';
|
||
this.currentStatusFilter = 'all';
|
||
this.currentSourceTypeFilter = 'all';
|
||
this.currentHourStart = '';
|
||
this.currentHourEnd = '';
|
||
this.tasksDisplayedCount = this.tasksPerPage;
|
||
|
||
// Réinitialiser les inputs d'heure
|
||
const hourStartInput = document.getElementById('task-cal-hour-start');
|
||
const hourEndInput = document.getElementById('task-cal-hour-end');
|
||
if (hourStartInput) hourStartInput.value = '';
|
||
if (hourEndInput) hourEndInput.value = '';
|
||
|
||
this.loadTaskLogsWithFilters();
|
||
this.showNotification('Tous les filtres effacés', 'info');
|
||
}
|
||
|
||
filterTasksBySourceType(sourceType) {
|
||
this.currentSourceTypeFilter = sourceType;
|
||
this.tasksDisplayedCount = this.tasksPerPage;
|
||
this.loadTaskLogsWithFilters();
|
||
}
|
||
|
||
clearSourceTypeFilter() {
|
||
this.currentSourceTypeFilter = 'all';
|
||
this.tasksDisplayedCount = this.tasksPerPage;
|
||
this.loadTaskLogsWithFilters();
|
||
this.showNotification('Filtre type effacé', 'info');
|
||
}
|
||
|
||
clearHourFilter() {
|
||
this.currentHourStart = '';
|
||
this.currentHourEnd = '';
|
||
|
||
// Réinitialiser les inputs d'heure
|
||
const hourStartInput = document.getElementById('task-cal-hour-start');
|
||
const hourEndInput = document.getElementById('task-cal-hour-end');
|
||
if (hourStartInput) hourStartInput.value = '';
|
||
if (hourEndInput) hourEndInput.value = '';
|
||
|
||
this.tasksDisplayedCount = this.tasksPerPage;
|
||
this.loadTaskLogsWithFilters();
|
||
this.showNotification('Filtre horaire effacé', 'info');
|
||
}
|
||
|
||
async loadTaskLogsWithFilters() {
|
||
// Afficher un indicateur de chargement inline (pas le loader global)
|
||
const container = document.getElementById('tasks-list');
|
||
const logsSection = document.getElementById('task-logs-section');
|
||
if (logsSection) {
|
||
logsSection.innerHTML = `
|
||
<h4 class="text-sm font-semibold text-gray-400 mb-2 mt-4"><i class="fas fa-spinner fa-spin mr-2"></i>Chargement...</h4>
|
||
`;
|
||
} else if (container) {
|
||
// Garder le header mais montrer le chargement dans la liste
|
||
const existingHeader = container.querySelector('.flex.flex-col.gap-2.mb-4');
|
||
if (!existingHeader) {
|
||
container.innerHTML = `
|
||
<div class="flex items-center justify-center py-8 text-gray-400">
|
||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||
<span>Chargement...</span>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
const params = new URLSearchParams();
|
||
if (this.currentStatusFilter && this.currentStatusFilter !== 'all') {
|
||
params.append('status', this.currentStatusFilter);
|
||
}
|
||
// Si plusieurs dates sont sélectionnées, utiliser le premier jour comme filtre principal (compat API)
|
||
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
|
||
const firstDate = this.parseDateKey(this.selectedTaskDates[0]);
|
||
const year = String(firstDate.getFullYear());
|
||
const month = String(firstDate.getMonth() + 1).padStart(2, '0');
|
||
const day = String(firstDate.getDate()).padStart(2, '0');
|
||
params.append('year', year);
|
||
params.append('month', month);
|
||
params.append('day', day);
|
||
} else {
|
||
if (this.currentDateFilter.year) params.append('year', this.currentDateFilter.year);
|
||
if (this.currentDateFilter.month) params.append('month', this.currentDateFilter.month);
|
||
if (this.currentDateFilter.day) params.append('day', this.currentDateFilter.day);
|
||
}
|
||
// Filtres d'heure
|
||
if (this.currentHourStart) {
|
||
params.append('hour_start', this.currentHourStart);
|
||
}
|
||
if (this.currentHourEnd) {
|
||
params.append('hour_end', this.currentHourEnd);
|
||
}
|
||
if (this.currentTargetFilter && this.currentTargetFilter !== 'all') {
|
||
params.append('target', this.currentTargetFilter);
|
||
}
|
||
if (this.currentCategoryFilter && this.currentCategoryFilter !== 'all') {
|
||
params.append('category', this.currentCategoryFilter);
|
||
}
|
||
// Filtre par type de source
|
||
if (this.currentSourceTypeFilter && this.currentSourceTypeFilter !== 'all') {
|
||
params.append('source_type', this.currentSourceTypeFilter);
|
||
}
|
||
// Pagination côté serveur
|
||
params.append('limit', this.tasksPerPage);
|
||
params.append('offset', 0); // Toujours commencer à 0 lors d'un nouveau filtre
|
||
|
||
try {
|
||
const result = await this.apiCall(`/api/tasks/logs?${params.toString()}`);
|
||
this.taskLogs = result.logs || [];
|
||
this.tasksTotalCount = result.total_count || 0;
|
||
this.tasksHasMore = result.has_more || false;
|
||
this.tasksDisplayedCount = this.taskLogs.length;
|
||
this.renderTasks();
|
||
this.updateTaskCounts();
|
||
} catch (error) {
|
||
console.error('Erreur chargement logs:', error);
|
||
}
|
||
}
|
||
|
||
updateTaskCounts() {
|
||
// Mettre à jour les compteurs dans les boutons
|
||
const stats = this.taskLogsStats || { total: 0, completed: 0, failed: 0, running: 0, pending: 0 };
|
||
const running = this.tasks.filter(t => t.status === 'running').length;
|
||
|
||
const countAll = document.getElementById('count-all');
|
||
const countRunning = document.getElementById('count-running');
|
||
const countCompleted = document.getElementById('count-completed');
|
||
const countFailed = document.getElementById('count-failed');
|
||
|
||
if (countAll) countAll.textContent = (stats.total || 0) + running;
|
||
if (countRunning) countRunning.textContent = running + (stats.running || 0);
|
||
if (countCompleted) countCompleted.textContent = stats.completed || 0;
|
||
if (countFailed) countFailed.textContent = stats.failed || 0;
|
||
|
||
// Mettre à jour le badge principal
|
||
const badge = document.getElementById('tasks-count-badge');
|
||
if (badge) badge.textContent = (stats.total || 0) + running;
|
||
|
||
console.log('Task counts updated:', { stats, running });
|
||
}
|
||
|
||
updateDateFilters() {
|
||
// Mettre à jour le libellé du bouton et le résumé sous le calendrier
|
||
const label = document.getElementById('task-date-filter-label');
|
||
const summary = document.getElementById('task-cal-summary');
|
||
|
||
let text = 'Toutes les dates';
|
||
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
|
||
if (this.selectedTaskDates.length === 1) {
|
||
const d = this.parseDateKey(this.selectedTaskDates[0]);
|
||
text = d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: '2-digit' });
|
||
} else {
|
||
text = `${this.selectedTaskDates.length} jours sélectionnés`;
|
||
}
|
||
}
|
||
|
||
if (label) label.textContent = text;
|
||
if (summary) summary.textContent = text;
|
||
}
|
||
|
||
applyDateFilter() {
|
||
// Lorsque plusieurs dates sont sélectionnées, on garde uniquement la première pour l'API
|
||
if (this.selectedTaskDates && this.selectedTaskDates.length > 0) {
|
||
const firstDate = this.parseDateKey(this.selectedTaskDates[0]);
|
||
this.currentDateFilter.year = String(firstDate.getFullYear());
|
||
this.currentDateFilter.month = String(firstDate.getMonth() + 1).padStart(2, '0');
|
||
this.currentDateFilter.day = String(firstDate.getDate()).padStart(2, '0');
|
||
} else {
|
||
this.currentDateFilter = { year: '', month: '', day: '' };
|
||
}
|
||
|
||
// Récupérer les heures depuis les inputs
|
||
const hourStartInput = document.getElementById('task-cal-hour-start');
|
||
const hourEndInput = document.getElementById('task-cal-hour-end');
|
||
this.currentHourStart = hourStartInput ? hourStartInput.value : '';
|
||
this.currentHourEnd = hourEndInput ? hourEndInput.value : '';
|
||
|
||
this.updateDateFilters();
|
||
this.loadTaskLogsWithFilters();
|
||
}
|
||
|
||
clearDateFilters() {
|
||
this.currentDateFilter = { year: '', month: '', day: '' };
|
||
this.selectedTaskDates = [];
|
||
this.currentHourStart = '';
|
||
this.currentHourEnd = '';
|
||
|
||
// Réinitialiser les inputs d'heure
|
||
const hourStartInput = document.getElementById('task-cal-hour-start');
|
||
const hourEndInput = document.getElementById('task-cal-hour-end');
|
||
if (hourStartInput) hourStartInput.value = '';
|
||
if (hourEndInput) hourEndInput.value = '';
|
||
|
||
this.updateDateFilters();
|
||
this.renderTaskCalendar();
|
||
this.loadTaskLogsWithFilters();
|
||
}
|
||
|
||
async refreshTaskLogs() {
|
||
// Ne pas utiliser showLoading() pour éviter le message "Exécution de la tâche..."
|
||
// Afficher un indicateur de chargement inline à la place
|
||
const container = document.getElementById('tasks-list');
|
||
if (container) {
|
||
container.innerHTML = `
|
||
<div class="flex items-center justify-center py-8 text-gray-400">
|
||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||
<span>Chargement des logs...</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
try {
|
||
const [taskLogsData, taskStatsData, taskDatesData] = await Promise.all([
|
||
this.apiCall(`/api/tasks/logs?limit=${this.tasksPerPage}&offset=0`),
|
||
this.apiCall('/api/tasks/logs/stats'),
|
||
this.apiCall('/api/tasks/logs/dates')
|
||
]);
|
||
|
||
this.taskLogs = taskLogsData.logs || [];
|
||
this.tasksTotalCount = taskLogsData.total_count || 0;
|
||
this.tasksHasMore = taskLogsData.has_more || false;
|
||
this.taskLogsStats = taskStatsData;
|
||
this.taskLogsDates = taskDatesData;
|
||
|
||
this.renderTasks();
|
||
this.updateDateFilters();
|
||
this.updateTaskCounts();
|
||
this.showNotification('Logs de tâches rafraîchis', 'success');
|
||
} catch (error) {
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
if (container) {
|
||
container.innerHTML = `
|
||
<div class="text-center text-red-400 py-8">
|
||
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
|
||
<p>Erreur de chargement</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
|
||
createTaskCard(task, isRunning) {
|
||
const statusBadge = this.getStatusBadge(task.status);
|
||
const progressBar = isRunning ? `
|
||
<div class="w-full bg-gray-700 rounded-full h-1.5 mt-2">
|
||
<div class="bg-blue-500 h-1.5 rounded-full transition-all duration-300 animate-pulse" style="width: ${task.progress || 50}%"></div>
|
||
</div>
|
||
` : '';
|
||
|
||
const startTime = task.start_time
|
||
? new Date(task.start_time).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||
: '--';
|
||
const duration = task.duration || '--';
|
||
|
||
// Icône selon le statut
|
||
const statusIcon = {
|
||
'completed': '<i class="fas fa-check-circle text-green-500"></i>',
|
||
'failed': '<i class="fas fa-times-circle text-red-500"></i>',
|
||
'running': '<i class="fas fa-cog fa-spin text-blue-500"></i>',
|
||
'pending': '<i class="fas fa-clock text-yellow-500"></i>'
|
||
}[task.status] || '<i class="fas fa-question-circle text-gray-500"></i>';
|
||
|
||
const taskCard = document.createElement('div');
|
||
taskCard.className = `host-card ${isRunning ? 'border-l-4 border-blue-500' : ''} ${task.status === 'failed' ? 'border-l-4 border-red-500' : ''}`;
|
||
taskCard.innerHTML = `
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex-1">
|
||
<div class="flex items-center space-x-3 mb-1">
|
||
${statusIcon}
|
||
<h4 class="font-semibold text-white text-sm">${task.name}</h4>
|
||
${statusBadge}
|
||
</div>
|
||
<p class="text-xs text-gray-400 ml-6">Cible: ${task.host}</p>
|
||
<p class="text-xs text-gray-500 ml-6">Début: ${startTime} • Durée: ${duration}</p>
|
||
${progressBar}
|
||
${task.output ? `
|
||
<div class="mt-2 ml-6">
|
||
<pre class="text-xs text-gray-500 bg-gray-800 p-2 rounded max-h-16 overflow-hidden cursor-pointer hover:bg-gray-750" onclick="dashboard.viewTaskDetails(${task.id})">${this.escapeHtml(task.output.substring(0, 150))}${task.output.length > 150 ? '...' : ''}</pre>
|
||
</div>
|
||
` : ''}
|
||
${task.error ? `
|
||
<div class="mt-2 ml-6">
|
||
<pre class="text-xs text-red-400 bg-red-900/20 p-2 rounded max-h-16 overflow-hidden">${this.escapeHtml(task.error.substring(0, 150))}${task.error.length > 150 ? '...' : ''}</pre>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="flex flex-col space-y-1 ml-2">
|
||
<button class="p-1.5 bg-gray-700 rounded hover:bg-gray-600 transition-colors" onclick="dashboard.viewTaskDetails(${task.id})" title="Voir les détails">
|
||
<i class="fas fa-eye text-gray-300 text-xs"></i>
|
||
</button>
|
||
${task.status === 'failed' ? `
|
||
<button class="p-1.5 bg-orange-700 rounded hover:bg-orange-600 transition-colors" onclick="dashboard.retryTask(${task.id})" title="Réessayer">
|
||
<i class="fas fa-redo text-white text-xs"></i>
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
return taskCard;
|
||
}
|
||
|
||
escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
viewTaskDetails(taskId) {
|
||
const task = this.tasks.find(t => t.id === taskId);
|
||
if (!task) {
|
||
this.showNotification('Tâche non trouvée', 'error');
|
||
return;
|
||
}
|
||
|
||
const statusBadge = this.getStatusBadge(task.status);
|
||
const startTime = task.start_time
|
||
? new Date(task.start_time).toLocaleString('fr-FR')
|
||
: '--';
|
||
|
||
this.showModal(`Détails de la tâche #${task.id}`, `
|
||
<div class="space-y-4">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="font-semibold text-lg">${task.name}</h3>
|
||
${statusBadge}
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||
<div class="p-3 bg-gray-800 rounded">
|
||
<span class="text-gray-400">Cible:</span>
|
||
<span class="ml-2 text-white">${task.host}</span>
|
||
</div>
|
||
<div class="p-3 bg-gray-800 rounded">
|
||
<span class="text-gray-400">Durée:</span>
|
||
<span class="ml-2 text-white">${task.duration || '--'}</span>
|
||
</div>
|
||
<div class="p-3 bg-gray-800 rounded">
|
||
<span class="text-gray-400">Début:</span>
|
||
<span class="ml-2 text-white">${startTime}</span>
|
||
</div>
|
||
<div class="p-3 bg-gray-800 rounded">
|
||
<span class="text-gray-400">Progression:</span>
|
||
<span class="ml-2 text-white">${task.progress}%</span>
|
||
</div>
|
||
</div>
|
||
|
||
${task.output ? `
|
||
<div>
|
||
<div class="flex items-center justify-between mb-2">
|
||
<h4 class="font-semibold text-sm text-gray-300">
|
||
<i class="fas fa-terminal mr-2"></i>Sortie
|
||
</h4>
|
||
<button onclick="dashboard.copyToClipboard(\`${this.escapeHtml(task.output).replace(/`/g, '\\`')}\`)" class="text-xs text-gray-400 hover:text-white">
|
||
<i class="fas fa-copy mr-1"></i>Copier
|
||
</button>
|
||
</div>
|
||
<pre class="text-xs text-gray-300 bg-gray-900 p-4 rounded max-h-64 overflow-auto whitespace-pre-wrap font-mono">${this.escapeHtml(task.output)}</pre>
|
||
</div>
|
||
` : ''}
|
||
|
||
${task.error ? `
|
||
<div>
|
||
<h4 class="font-semibold text-sm text-red-400 mb-2">
|
||
<i class="fas fa-exclamation-triangle mr-2"></i>Erreur
|
||
</h4>
|
||
<pre class="text-xs text-red-300 bg-red-900/30 p-4 rounded max-h-40 overflow-auto whitespace-pre-wrap font-mono">${this.escapeHtml(task.error)}</pre>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="flex space-x-3 pt-4">
|
||
${task.status === 'failed' ? `
|
||
<button onclick="dashboard.retryTask(${task.id}); dashboard.closeModal();" class="flex-1 px-4 py-2 bg-orange-600 rounded-lg hover:bg-orange-500 transition-colors">
|
||
<i class="fas fa-redo mr-2"></i>Réessayer
|
||
</button>
|
||
` : ''}
|
||
<button onclick="dashboard.closeModal()" class="flex-1 px-4 py-2 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
copyToClipboard(text) {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
this.showNotification('Copié dans le presse-papiers', 'success');
|
||
}).catch(() => {
|
||
this.showNotification('Erreur lors de la copie', 'error');
|
||
});
|
||
}
|
||
|
||
async clearCompletedTasks() {
|
||
const completedTasks = this.tasks.filter(t => t.status === 'completed' || t.status === 'failed');
|
||
if (completedTasks.length === 0) {
|
||
this.showNotification('Aucune tâche à nettoyer', 'info');
|
||
return;
|
||
}
|
||
|
||
// Supprimer localement les tâches terminées
|
||
this.tasks = this.tasks.filter(t => t.status === 'running' || t.status === 'pending');
|
||
this.renderTasks();
|
||
this.showNotification(`${completedTasks.length} tâche(s) nettoyée(s)`, 'success');
|
||
}
|
||
|
||
async retryTask(taskId) {
|
||
const task = this.tasks.find(t => t.id === taskId);
|
||
if (!task) return;
|
||
|
||
// Extraire l'action du nom de la tâche
|
||
const actionMap = {
|
||
'Mise à jour système': 'upgrade',
|
||
'Redémarrage système': 'reboot',
|
||
'Vérification de santé': 'health-check',
|
||
'Sauvegarde': 'backup'
|
||
};
|
||
|
||
const action = actionMap[task.name] || 'health-check';
|
||
await this.executeTask(action, task.host);
|
||
}
|
||
|
||
async cancelTask(taskId) {
|
||
if (!confirm('Êtes-vous sûr de vouloir annuler cette tâche ?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}/cancel`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-API-Key': this.apiKey,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.detail || 'Erreur lors de l\'annulation');
|
||
}
|
||
|
||
const result = await response.json();
|
||
this.showNotification(result.message || 'Tâche annulée avec succès', 'success');
|
||
|
||
// Mettre à jour la liste des tâches
|
||
const task = this.tasks.find(t => String(t.id) === String(taskId));
|
||
if (task) {
|
||
task.status = 'cancelled';
|
||
task.error = 'Tâche annulée par l\'utilisateur';
|
||
}
|
||
|
||
// Rafraîchir l'affichage
|
||
this.pollRunningTasks();
|
||
this.renderTasks();
|
||
|
||
} catch (error) {
|
||
console.error('Erreur annulation tâche:', error);
|
||
this.showNotification(error.message || 'Erreur lors de l\'annulation de la tâche', 'error');
|
||
}
|
||
}
|
||
|
||
showAdHocConsole() {
|
||
console.log('Opening Ad-Hoc Console with:', {
|
||
adhocHistory: this.adhocHistory,
|
||
adhocCategories: this.adhocCategories
|
||
});
|
||
|
||
const hostOptions = this.hosts.map(h =>
|
||
`<option value="${h.name}">${h.name} (${h.ip})</option>`
|
||
).join('');
|
||
|
||
const groupOptions = this.ansibleGroups.map(g =>
|
||
`<option value="${g}">${g}</option>`
|
||
).join('');
|
||
|
||
// Catégories par défaut si aucune n'est chargée
|
||
const categories = this.adhocCategories.length > 0 ? this.adhocCategories : [
|
||
{ name: 'default', description: 'Commandes générales', color: '#7c3aed', icon: 'fa-terminal' },
|
||
{ name: 'diagnostic', description: 'Diagnostic', color: '#10b981', icon: 'fa-stethoscope' },
|
||
{ name: 'maintenance', description: 'Maintenance', color: '#f59e0b', icon: 'fa-wrench' },
|
||
{ name: 'deployment', description: 'Déploiement', color: '#3b82f6', icon: 'fa-rocket' }
|
||
];
|
||
|
||
const categoryOptions = categories.map(c =>
|
||
`<option value="${c.name}">${c.name}</option>`
|
||
).join('');
|
||
|
||
// Stocker le filtre actuel (utiliser la propriété de classe)
|
||
this.currentHistoryCategoryFilter = this.currentHistoryCategoryFilter || 'all';
|
||
|
||
// Générer la liste de l'historique groupé par catégorie
|
||
const historyByCategory = {};
|
||
(this.adhocHistory || []).forEach(cmd => {
|
||
const cat = cmd.category || 'default';
|
||
if (!historyByCategory[cat]) historyByCategory[cat] = [];
|
||
historyByCategory[cat].push(cmd);
|
||
});
|
||
|
||
let historyHtml = '';
|
||
if (Object.keys(historyByCategory).length > 0) {
|
||
Object.entries(historyByCategory).forEach(([category, commands]) => {
|
||
// Filtrer par catégorie si un filtre est actif
|
||
if (this.currentHistoryCategoryFilter !== 'all' && category !== this.currentHistoryCategoryFilter) {
|
||
return; // Skip cette catégorie
|
||
}
|
||
|
||
const catInfo = categories.find(c => c.name === category) || { color: '#7c3aed', icon: 'fa-folder' };
|
||
historyHtml += `
|
||
<div class="mb-4 history-category-section" data-category="${category}">
|
||
<h5 class="text-xs font-semibold mb-2 flex items-center" style="color: ${catInfo.color}">
|
||
<i class="fas ${catInfo.icon} mr-2"></i>${category.toUpperCase()}
|
||
<span class="ml-2 text-gray-500">(${commands.length})</span>
|
||
</h5>
|
||
<div class="space-y-1">
|
||
${commands.map(cmd => `
|
||
<div class="flex items-center justify-between p-2 bg-gray-700/50 rounded hover:bg-gray-700 cursor-pointer group"
|
||
onclick="dashboard.loadHistoryCommand(\`${cmd.command.replace(/`/g, '\\`').replace(/\\/g, '\\\\')}\`, '${cmd.target}', '${cmd.module}', ${cmd.become})">
|
||
<div class="flex-1 min-w-0">
|
||
<code class="text-xs text-green-400 block truncate">${this.escapeHtml(cmd.command)}</code>
|
||
<span class="text-xs text-gray-500">${cmd.target}</span>
|
||
</div>
|
||
<div class="flex items-center space-x-1 ml-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<span class="text-xs text-gray-500 bg-gray-600 px-1 rounded">${cmd.use_count || 1}x</span>
|
||
<button onclick="event.stopPropagation(); dashboard.editHistoryCommand('${cmd.id}')"
|
||
class="p-1 hover:bg-gray-600 rounded" title="Modifier catégorie">
|
||
<i class="fas fa-tag text-xs text-gray-400"></i>
|
||
</button>
|
||
<button onclick="event.stopPropagation(); dashboard.deleteHistoryCommand('${cmd.id}')"
|
||
class="p-1 hover:bg-red-600 rounded" title="Supprimer">
|
||
<i class="fas fa-trash text-xs text-red-400"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
// Afficher les catégories disponibles avec filtrage et actions
|
||
// Ajouter "toutes" comme option de filtrage
|
||
let categoriesListHtml = `
|
||
<button type="button" onclick="dashboard.filterHistoryByCategory('all')"
|
||
data-category-filter="all"
|
||
class="category-filter-btn active inline-flex items-center px-2 py-1 rounded text-xs transition-all bg-purple-600 text-white hover:bg-purple-500">
|
||
<i class="fas fa-list mr-1"></i>Toutes
|
||
</button>
|
||
`;
|
||
categories.forEach(cat => {
|
||
const isDefault = cat.name === 'default';
|
||
categoriesListHtml += `
|
||
<div class="group relative inline-flex">
|
||
<button type="button" onclick="dashboard.filterHistoryByCategory('${cat.name}')"
|
||
data-category-filter="${cat.name}"
|
||
class="category-filter-btn inline-flex items-center px-2 py-1 rounded-l text-xs transition-all hover:brightness-125"
|
||
style="background-color: ${cat.color}30; color: ${cat.color}">
|
||
<i class="fas ${cat.icon} mr-1"></i>${cat.name}
|
||
</button>
|
||
<div class="inline-flex">
|
||
<button type="button" onclick="event.stopPropagation(); dashboard.editCategory('${cat.name}')"
|
||
class="px-1 py-1 text-xs opacity-60 hover:opacity-100 transition-opacity"
|
||
style="background-color: ${cat.color}20; color: ${cat.color}" title="Modifier">
|
||
<i class="fas fa-pen text-[9px]"></i>
|
||
</button>
|
||
${!isDefault ? `
|
||
<button type="button" onclick="event.stopPropagation(); dashboard.deleteCategory('${cat.name}')"
|
||
class="px-1 py-1 rounded-r text-xs opacity-60 hover:opacity-100 hover:bg-red-600 hover:text-white transition-all"
|
||
style="background-color: ${cat.color}20; color: ${cat.color}" title="Supprimer">
|
||
<i class="fas fa-times text-[9px]"></i>
|
||
</button>
|
||
` : `<span class="px-1 py-1 rounded-r text-xs" style="background-color: ${cat.color}20;"></span>`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
this.showModal('Console Ad-Hoc Ansible', `
|
||
<div class="flex flex-col lg:flex-row gap-6 adhoc-console-content">
|
||
<!-- Formulaire principal -->
|
||
<div class="flex-1 lg:flex-[3] min-w-0">
|
||
<form onsubmit="dashboard.executeAdHocCommand(event)" class="space-y-4">
|
||
<div class="p-3 bg-purple-900/30 border border-purple-600 rounded-lg text-sm">
|
||
<i class="fas fa-terminal text-purple-400 mr-2"></i>
|
||
Exécutez des commandes shell directement sur vos hôtes via Ansible
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Cible</label>
|
||
<select name="target" id="adhoc-target" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
|
||
<option value="all">Tous les hôtes</option>
|
||
<optgroup label="Groupes">
|
||
${groupOptions}
|
||
</optgroup>
|
||
<optgroup label="Hôtes">
|
||
${hostOptions}
|
||
</optgroup>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Module</label>
|
||
<select name="module" id="adhoc-module" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
|
||
<option value="shell">shell (avec pipes)</option>
|
||
<option value="command">command (simple)</option>
|
||
<option value="raw">raw (sans python)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Aperçu des hôtes ciblés -->
|
||
<div id="adhoc-target-hosts-preview" class="p-3 bg-gray-800/50 border border-gray-700 rounded-lg">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<span class="text-xs font-medium text-gray-400 uppercase tracking-wide">
|
||
<i class="fas fa-server mr-1"></i>Hôtes ciblés
|
||
</span>
|
||
<span id="adhoc-target-hosts-count" class="text-xs text-purple-400 font-medium"></span>
|
||
</div>
|
||
<div id="adhoc-target-hosts-list" class="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
||
<!-- Liste des hôtes sera injectée ici -->
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-code mr-1"></i>Commande
|
||
</label>
|
||
<input type="text" name="command" id="adhoc-command"
|
||
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none font-mono"
|
||
placeholder="df -h | grep '/$'" required>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center gap-4">
|
||
<label class="flex items-center space-x-2 cursor-pointer">
|
||
<input type="checkbox" name="become" id="adhoc-become" class="form-checkbox bg-gray-700 border-gray-600 rounded text-purple-500">
|
||
<span class="text-sm text-gray-300">Sudo (become)</span>
|
||
</label>
|
||
<div class="flex items-center space-x-2">
|
||
<label class="text-sm text-gray-300">Timeout:</label>
|
||
<input type="number" name="timeout" value="60" min="5" max="600"
|
||
class="w-20 p-2 bg-gray-700 border border-gray-600 rounded text-sm">
|
||
<span class="text-xs text-gray-500">sec</span>
|
||
</div>
|
||
<div class="flex items-center space-x-2">
|
||
<label class="text-sm text-gray-300">Catégorie:</label>
|
||
<select name="save_category" id="adhoc-category" class="p-2 bg-gray-700 border border-gray-600 rounded text-sm">
|
||
${categoryOptions}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="adhoc-result" class="hidden mt-4">
|
||
<div class="border border-gray-700 rounded-xl overflow-hidden bg-gray-900/80">
|
||
<!-- Header du résultat -->
|
||
<div id="adhoc-result-header" class="flex items-center justify-between px-4 py-2 bg-gray-800/80 border-b border-gray-700">
|
||
<div class="flex items-center gap-3">
|
||
<div id="adhoc-status-icon" class="w-7 h-7 rounded-lg flex items-center justify-center bg-gray-700">
|
||
<i class="fas fa-spinner fa-spin text-gray-400"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-semibold text-sm text-white">Résultat d'exécution</h4>
|
||
<p id="adhoc-result-meta" class="text-xs text-gray-500">En attente...</p>
|
||
</div>
|
||
</div>
|
||
<div id="adhoc-result-stats" class="flex items-center gap-3 text-xs">
|
||
<!-- Stats seront injectées ici -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Corps du résultat avec sections -->
|
||
<div class="p-3 space-y-3" style="max-height: 350px; overflow-y: auto;">
|
||
<!-- Section STDOUT -->
|
||
<div id="adhoc-stdout-section">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">
|
||
<i class="fas fa-terminal mr-1"></i>Sortie standard
|
||
</span>
|
||
</div>
|
||
<pre id="adhoc-stdout" class="text-sm bg-black/50 p-3 rounded-lg overflow-auto font-mono text-gray-300 leading-relaxed border border-gray-800" style="max-height: 250px;"></pre>
|
||
</div>
|
||
|
||
<!-- Section STDERR (conditionnelle) - collapsible -->
|
||
<div id="adhoc-stderr-section" class="hidden">
|
||
<details class="group">
|
||
<summary class="flex items-center gap-2 mb-1 cursor-pointer list-none">
|
||
<span class="text-xs font-medium text-amber-400 uppercase tracking-wide">
|
||
<i class="fas fa-exclamation-triangle mr-1"></i>Avertissements
|
||
<i class="fas fa-chevron-down ml-1 text-[10px] transition-transform group-open:rotate-180"></i>
|
||
</span>
|
||
</summary>
|
||
<pre id="adhoc-stderr" class="text-xs bg-amber-950/30 p-3 rounded-lg overflow-auto font-mono text-amber-200 leading-relaxed border border-amber-900/50" style="max-height: 100px;"></pre>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex space-x-3 pt-2">
|
||
<button type="submit" class="flex-1 btn-primary bg-purple-600 hover:bg-purple-500 py-3 rounded-lg font-medium">
|
||
<i class="fas fa-play mr-2"></i>Exécuter
|
||
</button>
|
||
<button type="button" onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Historique des commandes -->
|
||
<div class="w-full lg:w-72 xl:w-80 flex-shrink-0">
|
||
<div class="p-4 bg-gray-800/50 rounded-lg sticky top-0">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h4 class="text-sm font-semibold text-gray-300">
|
||
<i class="fas fa-history mr-2"></i>Historique
|
||
</h4>
|
||
<button onclick="dashboard.showAddCategoryModal()" class="text-xs text-purple-400 hover:text-purple-300">
|
||
<i class="fas fa-plus mr-1"></i>Catégorie
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Catégories disponibles -->
|
||
<div class="mb-3 pb-3 border-b border-gray-700">
|
||
<p class="text-xs text-gray-500 mb-2">Catégories:</p>
|
||
<div class="flex flex-wrap gap-1">
|
||
${categoriesListHtml}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Liste des commandes - hauteur augmentée -->
|
||
<div id="adhoc-history-container" class="overflow-y-auto" style="max-height: 400px;">
|
||
${historyHtml || '<p class="text-xs text-gray-500 text-center py-4"><i class="fas fa-inbox mr-2"></i>Aucune commande dans l\'historique<br><span class="text-gray-600">Exécutez une commande pour la sauvegarder</span></p>'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`);
|
||
|
||
// Initialiser l'aperçu des hôtes ciblés
|
||
this.updateTargetHostsPreview('all');
|
||
|
||
// Ajouter l'event listener pour mettre à jour l'aperçu quand la cible change
|
||
const targetSelect = document.getElementById('adhoc-target');
|
||
if (targetSelect) {
|
||
targetSelect.addEventListener('change', (e) => {
|
||
this.updateTargetHostsPreview(e.target.value);
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Récupère la liste des hôtes pour une cible donnée (groupe, hôte individuel ou "all")
|
||
*/
|
||
getHostsForTarget(target) {
|
||
if (!target || target === 'all') {
|
||
// Tous les hôtes
|
||
return this.hosts;
|
||
}
|
||
|
||
// Vérifier si c'est un groupe
|
||
if (this.ansibleGroups.includes(target)) {
|
||
return this.hosts.filter(h => h.groups && h.groups.includes(target));
|
||
}
|
||
|
||
// Sinon c'est un hôte individuel
|
||
const host = this.hosts.find(h => h.name === target);
|
||
return host ? [host] : [];
|
||
}
|
||
|
||
/**
|
||
* Met à jour l'aperçu des hôtes ciblés dans la console Ad-Hoc
|
||
*/
|
||
updateTargetHostsPreview(target) {
|
||
const listContainer = document.getElementById('adhoc-target-hosts-list');
|
||
const countSpan = document.getElementById('adhoc-target-hosts-count');
|
||
const previewContainer = document.getElementById('adhoc-target-hosts-preview');
|
||
|
||
if (!listContainer || !countSpan || !previewContainer) return;
|
||
|
||
const hosts = this.getHostsForTarget(target);
|
||
|
||
// Mettre à jour le compteur
|
||
countSpan.textContent = `${hosts.length} hôte${hosts.length > 1 ? 's' : ''}`;
|
||
|
||
// Générer les badges des hôtes
|
||
if (hosts.length === 0) {
|
||
listContainer.innerHTML = '<span class="text-xs text-gray-500 italic">Aucun hôte trouvé</span>';
|
||
previewContainer.classList.add('border-amber-700/50');
|
||
previewContainer.classList.remove('border-gray-700');
|
||
} else {
|
||
previewContainer.classList.remove('border-amber-700/50');
|
||
previewContainer.classList.add('border-gray-700');
|
||
|
||
listContainer.innerHTML = hosts.map(h => {
|
||
const statusColor = h.bootstrap_ok ? 'bg-green-900/40 text-green-400 border-green-700/50' : 'bg-gray-700/50 text-gray-400 border-gray-600/50';
|
||
const statusIcon = h.bootstrap_ok ? 'fa-check-circle' : 'fa-circle';
|
||
return `
|
||
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border ${statusColor}" title="${h.ip || h.name}${h.groups ? ' • Groupes: ' + h.groups.join(', ') : ''}">
|
||
<i class="fas ${statusIcon} text-[8px]"></i>
|
||
<span class="font-medium">${this.escapeHtml(h.name)}</span>
|
||
${h.ip ? `<span class="text-gray-500 text-[10px]">${h.ip}</span>` : ''}
|
||
</span>
|
||
`;
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
loadHistoryCommand(command, target, module, become) {
|
||
document.getElementById('adhoc-command').value = command;
|
||
document.getElementById('adhoc-target').value = target;
|
||
document.getElementById('adhoc-module').value = module;
|
||
document.getElementById('adhoc-become').checked = become;
|
||
// Mettre à jour l'aperçu des hôtes ciblés
|
||
this.updateTargetHostsPreview(target);
|
||
this.showNotification('Commande chargée depuis l\'historique', 'info');
|
||
}
|
||
|
||
/**
|
||
* Rafraîchit dynamiquement la section historique des commandes Ad-Hoc
|
||
* sans recharger toute la modale
|
||
*/
|
||
async refreshAdHocHistory() {
|
||
try {
|
||
// Récupérer l'historique mis à jour depuis l'API
|
||
const historyData = await this.apiCall('/api/adhoc/history');
|
||
this.adhocHistory = historyData.commands || [];
|
||
|
||
const historyContainer = document.getElementById('adhoc-history-container');
|
||
if (!historyContainer) return;
|
||
|
||
// Catégories pour le rendu
|
||
const categories = this.adhocCategories.length > 0 ? this.adhocCategories : [
|
||
{ name: 'default', description: 'Commandes générales', color: '#7c3aed', icon: 'fa-terminal' },
|
||
{ name: 'diagnostic', description: 'Diagnostic', color: '#10b981', icon: 'fa-stethoscope' },
|
||
{ name: 'maintenance', description: 'Maintenance', color: '#f59e0b', icon: 'fa-wrench' },
|
||
{ name: 'deployment', description: 'Déploiement', color: '#3b82f6', icon: 'fa-rocket' }
|
||
];
|
||
|
||
// Générer la liste de l'historique groupé par catégorie
|
||
const historyByCategory = {};
|
||
(this.adhocHistory || []).forEach(cmd => {
|
||
const cat = cmd.category || 'default';
|
||
if (!historyByCategory[cat]) historyByCategory[cat] = [];
|
||
historyByCategory[cat].push(cmd);
|
||
});
|
||
|
||
let historyHtml = '';
|
||
if (Object.keys(historyByCategory).length > 0) {
|
||
Object.entries(historyByCategory).forEach(([category, commands]) => {
|
||
// Filtrer par catégorie si un filtre est actif
|
||
if (this.currentHistoryCategoryFilter !== 'all' && category !== this.currentHistoryCategoryFilter) {
|
||
return;
|
||
}
|
||
|
||
const catInfo = categories.find(c => c.name === category) || { color: '#7c3aed', icon: 'fa-folder' };
|
||
historyHtml += `
|
||
<div class="mb-4 history-category-section" data-category="${category}">
|
||
<h5 class="text-xs font-semibold mb-2 flex items-center" style="color: ${catInfo.color}">
|
||
<i class="fas ${catInfo.icon} mr-2"></i>${category.toUpperCase()}
|
||
<span class="ml-2 text-gray-500">(${commands.length})</span>
|
||
</h5>
|
||
<div class="space-y-1">
|
||
${commands.map(cmd => `
|
||
<div class="flex items-center justify-between p-2 bg-gray-700/50 rounded hover:bg-gray-700 cursor-pointer group"
|
||
onclick="dashboard.loadHistoryCommand(\`${cmd.command.replace(/`/g, '\\`').replace(/\\/g, '\\\\')}\`, '${cmd.target}', '${cmd.module}', ${cmd.become})">
|
||
<div class="flex-1 min-w-0">
|
||
<code class="text-xs text-green-400 block truncate">${this.escapeHtml(cmd.command)}</code>
|
||
<span class="text-xs text-gray-500">${cmd.target}</span>
|
||
</div>
|
||
<div class="flex items-center space-x-1 ml-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<span class="text-xs text-gray-500 bg-gray-600 px-1 rounded">${cmd.use_count || 1}x</span>
|
||
<button onclick="event.stopPropagation(); dashboard.editHistoryCommand('${cmd.id}')"
|
||
class="p-1 hover:bg-gray-600 rounded" title="Modifier catégorie">
|
||
<i class="fas fa-tag text-xs text-gray-400"></i>
|
||
</button>
|
||
<button onclick="event.stopPropagation(); dashboard.deleteHistoryCommand('${cmd.id}')"
|
||
class="p-1 hover:bg-red-600 rounded" title="Supprimer">
|
||
<i class="fas fa-trash text-xs text-red-400"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
// Mettre à jour le contenu avec animation
|
||
historyContainer.style.opacity = '0.5';
|
||
historyContainer.innerHTML = historyHtml || '<p class="text-xs text-gray-500 text-center py-4"><i class="fas fa-inbox mr-2"></i>Aucune commande dans l\'historique<br><span class="text-gray-600">Exécutez une commande pour la sauvegarder</span></p>';
|
||
|
||
// Animation de mise à jour
|
||
setTimeout(() => {
|
||
historyContainer.style.opacity = '1';
|
||
historyContainer.style.transition = 'opacity 0.3s ease';
|
||
}, 50);
|
||
|
||
} catch (error) {
|
||
console.error('Erreur lors du rafraîchissement de l\'historique:', error);
|
||
}
|
||
}
|
||
|
||
async deleteHistoryCommand(commandId) {
|
||
if (!confirm('Supprimer cette commande de l\'historique ?')) return;
|
||
|
||
try {
|
||
await this.apiCall(`/api/adhoc/history/${commandId}`, { method: 'DELETE' });
|
||
this.showNotification('Commande supprimée', 'success');
|
||
// Recharger l'historique et réafficher la console
|
||
const historyData = await this.apiCall('/api/adhoc/history');
|
||
this.adhocHistory = historyData.commands || [];
|
||
this.showAdHocConsole();
|
||
} catch (error) {
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async editHistoryCommand(commandId) {
|
||
const categoryOptions = this.adhocCategories.map(c =>
|
||
`<option value="${c.name}">${c.name}</option>`
|
||
).join('');
|
||
|
||
this.showModal('Modifier la catégorie', `
|
||
<form onsubmit="dashboard.updateCommandCategory(event, '${commandId}')" class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Catégorie</label>
|
||
<select name="category" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
|
||
${categoryOptions}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Description (optionnel)</label>
|
||
<input type="text" name="description" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg"
|
||
placeholder="Description de la commande">
|
||
</div>
|
||
<div class="flex space-x-3">
|
||
<button type="submit" class="flex-1 btn-primary">Enregistrer</button>
|
||
<button type="button" onclick="dashboard.showAdHocConsole()" class="px-4 py-2 border border-gray-600 rounded-lg">Annuler</button>
|
||
</div>
|
||
</form>
|
||
`);
|
||
}
|
||
|
||
async updateCommandCategory(event, commandId) {
|
||
event.preventDefault();
|
||
const formData = new FormData(event.target);
|
||
|
||
try {
|
||
await this.apiCall(`/api/adhoc/history/${commandId}/category?category=${encodeURIComponent(formData.get('category'))}&description=${encodeURIComponent(formData.get('description') || '')}`, {
|
||
method: 'PUT'
|
||
});
|
||
this.showNotification('Catégorie mise à jour', 'success');
|
||
const historyData = await this.apiCall('/api/adhoc/history');
|
||
this.adhocHistory = historyData.commands || [];
|
||
this.showAdHocConsole();
|
||
} catch (error) {
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
showAddCategoryModal() {
|
||
this.showModal('Ajouter une catégorie', `
|
||
<form onsubmit="dashboard.createCategory(event)" class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Nom</label>
|
||
<input type="text" name="name" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg"
|
||
placeholder="monitoring" required>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Description</label>
|
||
<input type="text" name="description" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg"
|
||
placeholder="Commandes de monitoring">
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Couleur</label>
|
||
<input type="color" name="color" value="#7c3aed" class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Icône</label>
|
||
<select name="icon" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
|
||
<option value="fa-folder">Dossier</option>
|
||
<option value="fa-terminal">Terminal</option>
|
||
<option value="fa-server">Serveur</option>
|
||
<option value="fa-database">Base de données</option>
|
||
<option value="fa-network-wired">Réseau</option>
|
||
<option value="fa-shield-alt">Sécurité</option>
|
||
<option value="fa-chart-line">Monitoring</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="flex space-x-3">
|
||
<button type="submit" class="flex-1 btn-primary">Créer</button>
|
||
<button type="button" onclick="dashboard.showAdHocConsole()" class="px-4 py-2 border border-gray-600 rounded-lg">Annuler</button>
|
||
</div>
|
||
</form>
|
||
`);
|
||
}
|
||
|
||
async createCategory(event) {
|
||
event.preventDefault();
|
||
const formData = new FormData(event.target);
|
||
|
||
try {
|
||
await this.apiCall(`/api/adhoc/categories?name=${encodeURIComponent(formData.get('name'))}&description=${encodeURIComponent(formData.get('description') || '')}&color=${encodeURIComponent(formData.get('color'))}&icon=${encodeURIComponent(formData.get('icon'))}`, {
|
||
method: 'POST'
|
||
});
|
||
this.showNotification('Catégorie créée', 'success');
|
||
const categoriesData = await this.apiCall('/api/adhoc/categories');
|
||
this.adhocCategories = categoriesData.categories || [];
|
||
this.showAdHocConsole();
|
||
} catch (error) {
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
filterHistoryByCategory(category) {
|
||
this.currentHistoryCategoryFilter = category;
|
||
|
||
// Mettre à jour les boutons de filtre visuellement
|
||
document.querySelectorAll('.category-filter-btn').forEach(btn => {
|
||
const btnCategory = btn.getAttribute('data-category-filter');
|
||
if (btnCategory === category) {
|
||
if (category === 'all') {
|
||
btn.className = 'category-filter-btn active inline-flex items-center px-2 py-1 rounded text-xs transition-all bg-purple-600 text-white hover:bg-purple-500';
|
||
} else {
|
||
btn.classList.add('active', 'ring-2', 'ring-white/50');
|
||
}
|
||
} else {
|
||
btn.classList.remove('active', 'ring-2', 'ring-white/50');
|
||
if (btnCategory === 'all') {
|
||
btn.className = 'category-filter-btn inline-flex items-center px-2 py-1 rounded text-xs transition-all bg-gray-700 text-gray-400 hover:bg-gray-600';
|
||
}
|
||
}
|
||
});
|
||
|
||
// Filtrer les sections de l'historique
|
||
document.querySelectorAll('.history-category-section').forEach(section => {
|
||
const sectionCategory = section.getAttribute('data-category');
|
||
if (category === 'all' || sectionCategory === category) {
|
||
section.classList.remove('hidden');
|
||
} else {
|
||
section.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Si aucun résultat visible, afficher un message
|
||
const visibleSections = document.querySelectorAll('.history-category-section:not(.hidden)');
|
||
const historyContainer = document.querySelector('.overflow-y-auto[style*="max-height: 400px"]');
|
||
if (historyContainer && visibleSections.length === 0) {
|
||
// Pas de commandes dans cette catégorie
|
||
const emptyMsg = historyContainer.querySelector('.empty-filter-msg');
|
||
if (!emptyMsg) {
|
||
const msg = document.createElement('p');
|
||
msg.className = 'empty-filter-msg text-xs text-gray-500 text-center py-4';
|
||
msg.innerHTML = '<i class="fas fa-filter mr-2"></i>Aucune commande dans cette catégorie';
|
||
historyContainer.appendChild(msg);
|
||
}
|
||
} else {
|
||
const emptyMsg = historyContainer?.querySelector('.empty-filter-msg');
|
||
if (emptyMsg) emptyMsg.remove();
|
||
}
|
||
}
|
||
|
||
editCategory(categoryName) {
|
||
const category = this.adhocCategories.find(c => c.name === categoryName);
|
||
if (!category) {
|
||
this.showNotification('Catégorie non trouvée', 'error');
|
||
return;
|
||
}
|
||
|
||
this.showModal(`Modifier la catégorie: ${categoryName}`, `
|
||
<form onsubmit="dashboard.updateCategory(event, '${categoryName}')" class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Nom</label>
|
||
<input type="text" name="name" value="${this.escapeHtml(category.name)}"
|
||
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg"
|
||
${categoryName === 'default' ? 'disabled' : ''} required>
|
||
${categoryName === 'default' ? '<p class="text-xs text-gray-500 mt-1">La catégorie par défaut ne peut pas être renommée</p>' : ''}
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Description</label>
|
||
<input type="text" name="description" value="${this.escapeHtml(category.description || '')}"
|
||
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg"
|
||
placeholder="Description de la catégorie">
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Couleur</label>
|
||
<input type="color" name="color" value="${category.color || '#7c3aed'}"
|
||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Icône</label>
|
||
<select name="icon" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
|
||
<option value="fa-folder" ${category.icon === 'fa-folder' ? 'selected' : ''}>📁 Dossier</option>
|
||
<option value="fa-terminal" ${category.icon === 'fa-terminal' ? 'selected' : ''}>💻 Terminal</option>
|
||
<option value="fa-server" ${category.icon === 'fa-server' ? 'selected' : ''}>🖥️ Serveur</option>
|
||
<option value="fa-database" ${category.icon === 'fa-database' ? 'selected' : ''}>🗄️ Base de données</option>
|
||
<option value="fa-network-wired" ${category.icon === 'fa-network-wired' ? 'selected' : ''}>🌐 Réseau</option>
|
||
<option value="fa-shield-alt" ${category.icon === 'fa-shield-alt' ? 'selected' : ''}>🛡️ Sécurité</option>
|
||
<option value="fa-chart-line" ${category.icon === 'fa-chart-line' ? 'selected' : ''}>📈 Monitoring</option>
|
||
<option value="fa-wrench" ${category.icon === 'fa-wrench' ? 'selected' : ''}>🔧 Maintenance</option>
|
||
<option value="fa-rocket" ${category.icon === 'fa-rocket' ? 'selected' : ''}>🚀 Déploiement</option>
|
||
<option value="fa-stethoscope" ${category.icon === 'fa-stethoscope' ? 'selected' : ''}>🩺 Diagnostic</option>
|
||
<option value="fa-cog" ${category.icon === 'fa-cog' ? 'selected' : ''}>⚙️ Configuration</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Aperçu -->
|
||
<div class="p-3 bg-gray-800 rounded-lg">
|
||
<p class="text-xs text-gray-500 mb-2">Aperçu:</p>
|
||
<span class="inline-flex items-center px-3 py-1.5 rounded text-sm"
|
||
style="background-color: ${category.color}30; color: ${category.color}">
|
||
<i class="fas ${category.icon} mr-2"></i>${category.name}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="flex space-x-3">
|
||
<button type="submit" class="flex-1 btn-primary bg-purple-600 hover:bg-purple-500">
|
||
<i class="fas fa-save mr-2"></i>Enregistrer
|
||
</button>
|
||
<button type="button" onclick="dashboard.showAdHocConsole()"
|
||
class="px-4 py-2 border border-gray-600 rounded-lg hover:border-gray-500">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</form>
|
||
`);
|
||
}
|
||
|
||
async updateCategory(event, originalName) {
|
||
event.preventDefault();
|
||
const formData = new FormData(event.target);
|
||
const newName = formData.get('name') || originalName;
|
||
|
||
try {
|
||
await this.apiCall(`/api/adhoc/categories/${encodeURIComponent(originalName)}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
name: newName,
|
||
description: formData.get('description') || '',
|
||
color: formData.get('color'),
|
||
icon: formData.get('icon')
|
||
})
|
||
});
|
||
this.showNotification('Catégorie mise à jour', 'success');
|
||
const categoriesData = await this.apiCall('/api/adhoc/categories');
|
||
this.adhocCategories = categoriesData.categories || [];
|
||
this.showAdHocConsole();
|
||
} catch (error) {
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async deleteCategory(categoryName) {
|
||
if (categoryName === 'default') {
|
||
this.showNotification('La catégorie par défaut ne peut pas être supprimée', 'error');
|
||
return;
|
||
}
|
||
|
||
// Compter les commandes dans cette catégorie
|
||
const commandsInCategory = (this.adhocHistory || []).filter(cmd => cmd.category === categoryName).length;
|
||
|
||
const confirmMsg = commandsInCategory > 0
|
||
? `Supprimer la catégorie "${categoryName}" ?\n\n${commandsInCategory} commande(s) seront déplacées vers "default".`
|
||
: `Supprimer la catégorie "${categoryName}" ?`;
|
||
|
||
if (!confirm(confirmMsg)) return;
|
||
|
||
try {
|
||
await this.apiCall(`/api/adhoc/categories/${encodeURIComponent(categoryName)}`, {
|
||
method: 'DELETE'
|
||
});
|
||
this.showNotification('Catégorie supprimée', 'success');
|
||
|
||
// Recharger les données
|
||
const [categoriesData, historyData] = await Promise.all([
|
||
this.apiCall('/api/adhoc/categories'),
|
||
this.apiCall('/api/adhoc/history')
|
||
]);
|
||
this.adhocCategories = categoriesData.categories || [];
|
||
this.adhocHistory = historyData.commands || [];
|
||
|
||
// Réinitialiser le filtre si on filtrait sur cette catégorie
|
||
if (this.currentHistoryCategoryFilter === categoryName) {
|
||
this.currentHistoryCategoryFilter = 'all';
|
||
}
|
||
|
||
this.showAdHocConsole();
|
||
} catch (error) {
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async executeAdHocCommand(event) {
|
||
event.preventDefault();
|
||
const formData = new FormData(event.target);
|
||
|
||
const payload = {
|
||
target: formData.get('target'),
|
||
command: formData.get('command'),
|
||
module: formData.get('module'),
|
||
become: formData.get('become') === 'on',
|
||
timeout: parseInt(formData.get('timeout')) || 60,
|
||
// catégorie d'historique choisie dans le select
|
||
category: formData.get('save_category') || 'default'
|
||
};
|
||
|
||
const resultDiv = document.getElementById('adhoc-result');
|
||
const stdoutPre = document.getElementById('adhoc-stdout');
|
||
const stderrPre = document.getElementById('adhoc-stderr');
|
||
const stderrSection = document.getElementById('adhoc-stderr-section');
|
||
const statusIcon = document.getElementById('adhoc-status-icon');
|
||
const resultMeta = document.getElementById('adhoc-result-meta');
|
||
const resultStats = document.getElementById('adhoc-result-stats');
|
||
const resultHeader = document.getElementById('adhoc-result-header');
|
||
|
||
// Reset et afficher
|
||
resultDiv.classList.remove('hidden');
|
||
stderrSection.classList.add('hidden');
|
||
resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-gray-800/80 border-b border-gray-700';
|
||
statusIcon.innerHTML = '<i class="fas fa-spinner fa-spin text-blue-400"></i>';
|
||
statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-blue-900/50';
|
||
resultMeta.textContent = 'Exécution en cours...';
|
||
resultStats.innerHTML = '';
|
||
stdoutPre.innerHTML = '<span class="text-blue-400 animate-pulse">⏳ Exécution de la commande...</span>';
|
||
|
||
try {
|
||
const result = await this.apiCall('/api/ansible/adhoc', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
// Mise à jour du header avec le statut
|
||
if (result.success) {
|
||
resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-green-900/30 border-b border-green-800/50';
|
||
statusIcon.innerHTML = '<i class="fas fa-check text-green-400"></i>';
|
||
statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-green-900/50';
|
||
resultMeta.innerHTML = `<span class="text-green-400 font-medium">Succès</span> • Cible: ${this.escapeHtml(result.target)}`;
|
||
} else {
|
||
resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-red-900/30 border-b border-red-800/50';
|
||
statusIcon.innerHTML = '<i class="fas fa-times text-red-400"></i>';
|
||
statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-red-900/50';
|
||
resultMeta.innerHTML = `<span class="text-red-400 font-medium">Échec</span> • Cible: ${this.escapeHtml(result.target)}`;
|
||
}
|
||
|
||
// Stats dans le header
|
||
resultStats.innerHTML = `
|
||
<div class="flex items-center gap-1 px-2 py-1 bg-gray-700/50 rounded">
|
||
<i class="fas fa-clock text-gray-400"></i>
|
||
<span class="text-gray-300">${result.duration}s</span>
|
||
</div>
|
||
<div class="flex items-center gap-1 px-2 py-1 bg-gray-700/50 rounded">
|
||
<i class="fas fa-hashtag text-gray-400"></i>
|
||
<span class="text-gray-300">Code: ${result.return_code}</span>
|
||
</div>
|
||
`;
|
||
|
||
// Parser et afficher le résultat avec onglets par hôte
|
||
const stdoutContent = result.stdout || '(pas de sortie)';
|
||
const hostOutputs = this.parseOutputByHost(stdoutContent);
|
||
|
||
// Si plusieurs hôtes, afficher avec onglets
|
||
if (hostOutputs.length > 1) {
|
||
this.renderHostTabs(hostOutputs, result.success);
|
||
} else {
|
||
// Un seul hôte ou output non parsable
|
||
stdoutPre.innerHTML = this.formatAnsibleOutput(stdoutContent, result.success);
|
||
}
|
||
|
||
// Afficher STDERR si présent
|
||
if (result.stderr && result.stderr.trim()) {
|
||
stderrSection.classList.remove('hidden');
|
||
stderrPre.innerHTML = this.formatAnsibleWarnings(result.stderr);
|
||
}
|
||
|
||
this.showNotification(
|
||
result.success ? 'Commande exécutée avec succès' : 'Commande échouée',
|
||
result.success ? 'success' : 'error'
|
||
);
|
||
|
||
// Mettre à jour dynamiquement l'historique des commandes
|
||
await this.refreshAdHocHistory();
|
||
|
||
} catch (error) {
|
||
resultHeader.className = 'flex items-center justify-between px-4 py-3 bg-red-900/30 border-b border-red-800/50';
|
||
statusIcon.innerHTML = '<i class="fas fa-exclamation-circle text-red-400"></i>';
|
||
statusIcon.className = 'w-8 h-8 rounded-lg flex items-center justify-center bg-red-900/50';
|
||
resultMeta.innerHTML = '<span class="text-red-400 font-medium">Erreur de connexion</span>';
|
||
resultStats.innerHTML = '';
|
||
stdoutPre.innerHTML = `<span class="text-red-400">❌ ${this.escapeHtml(error.message)}</span>`;
|
||
this.showNotification('Erreur lors de l\'exécution', 'error');
|
||
}
|
||
}
|
||
|
||
formatAnsibleOutput(output, isSuccess) {
|
||
// Formater la sortie Ansible pour une meilleure lisibilité
|
||
let formatted = this.escapeHtml(output);
|
||
|
||
// Colorer les hosts avec statut
|
||
formatted = formatted.replace(/^(\S+)\s*\|\s*(CHANGED|SUCCESS)\s*=>/gm,
|
||
'<span class="text-green-400 font-semibold">$1</span> <span class="px-1.5 py-0.5 bg-green-900/50 text-green-300 text-xs rounded">$2</span> =>');
|
||
formatted = formatted.replace(/^(\S+)\s*\|\s*(FAILED|UNREACHABLE)\s*(!)?\s*=>/gm,
|
||
'<span class="text-red-400 font-semibold">$1</span> <span class="px-1.5 py-0.5 bg-red-900/50 text-red-300 text-xs rounded">$2</span> =>');
|
||
|
||
// Colorer les clés JSON
|
||
formatted = formatted.replace(/"(\w+)"\s*:/g, '<span class="text-purple-400">"$1"</span>:');
|
||
|
||
// Colorer les valeurs importantes
|
||
formatted = formatted.replace(/: "([^"]+)"/g, ': <span class="text-cyan-300">"$1"</span>');
|
||
formatted = formatted.replace(/: (true|false)/g, ': <span class="text-yellow-400">$1</span>');
|
||
formatted = formatted.replace(/: (\d+)/g, ': <span class="text-orange-400">$1</span>');
|
||
|
||
// Mettre en évidence les lignes de résumé
|
||
formatted = formatted.replace(/^(PLAY RECAP \*+)$/gm, '<span class="text-purple-400 font-bold">$1</span>');
|
||
formatted = formatted.replace(/(ok=\d+)/g, '<span class="text-green-400">$1</span>');
|
||
formatted = formatted.replace(/(changed=\d+)/g, '<span class="text-yellow-400">$1</span>');
|
||
formatted = formatted.replace(/(unreachable=\d+)/g, '<span class="text-orange-400">$1</span>');
|
||
formatted = formatted.replace(/(failed=\d+)/g, '<span class="text-red-400">$1</span>');
|
||
|
||
return formatted;
|
||
}
|
||
|
||
formatAnsibleWarnings(stderr) {
|
||
let formatted = this.escapeHtml(stderr);
|
||
|
||
// Mettre en évidence les warnings
|
||
formatted = formatted.replace(/\[WARNING\]:/g, '<span class="text-amber-400 font-semibold">[WARNING]:</span>');
|
||
formatted = formatted.replace(/\[DEPRECATION WARNING\]:/g, '<span class="text-orange-400 font-semibold">[DEPRECATION WARNING]:</span>');
|
||
|
||
// Colorer les URLs
|
||
formatted = formatted.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" class="text-blue-400 hover:underline">$1</a>');
|
||
|
||
// Mettre en évidence les chemins de fichiers
|
||
formatted = formatted.replace(/(\/[\w\-\.\/]+)/g, '<span class="text-gray-400">$1</span>');
|
||
|
||
return formatted;
|
||
}
|
||
|
||
parseOutputByHost(output) {
|
||
// Parser la sortie Ansible pour séparer par hôte
|
||
// Format typique: "hostname | STATUS | rc=X >>"
|
||
const hostOutputs = [];
|
||
const lines = output.split('\n');
|
||
let currentHost = null;
|
||
let currentOutput = [];
|
||
let currentStatus = 'unknown';
|
||
|
||
const hostPattern = /^(\S+)\s*\|\s*(CHANGED|SUCCESS|FAILED|UNREACHABLE)\s*\|?\s*rc=(\d+)\s*>>?/;
|
||
|
||
for (const line of lines) {
|
||
const match = line.match(hostPattern);
|
||
if (match) {
|
||
// Sauvegarder l'hôte précédent si existant
|
||
if (currentHost) {
|
||
hostOutputs.push({
|
||
hostname: currentHost,
|
||
status: currentStatus,
|
||
output: currentOutput.join('\n').trim()
|
||
});
|
||
}
|
||
// Commencer un nouvel hôte
|
||
currentHost = match[1];
|
||
currentStatus = match[2].toLowerCase();
|
||
currentOutput = [line];
|
||
} else if (currentHost) {
|
||
currentOutput.push(line);
|
||
} else {
|
||
// Lignes avant le premier hôte (header, etc.)
|
||
if (!hostOutputs.length && line.trim()) {
|
||
currentOutput.push(line);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ajouter le dernier hôte
|
||
if (currentHost) {
|
||
hostOutputs.push({
|
||
hostname: currentHost,
|
||
status: currentStatus,
|
||
output: currentOutput.join('\n').trim()
|
||
});
|
||
}
|
||
|
||
// Si aucun hôte trouvé, retourner l'output brut
|
||
if (hostOutputs.length === 0) {
|
||
return [{
|
||
hostname: 'output',
|
||
status: 'unknown',
|
||
output: output
|
||
}];
|
||
}
|
||
|
||
return hostOutputs;
|
||
}
|
||
|
||
renderHostTabs(hostOutputs, isSuccess) {
|
||
const stdoutSection = document.getElementById('adhoc-stdout-section');
|
||
if (!stdoutSection) return;
|
||
|
||
// Stocker les outputs pour référence
|
||
this.currentHostOutputs = hostOutputs;
|
||
|
||
// Générer les onglets
|
||
const tabsHtml = hostOutputs.map((host, index) => {
|
||
const statusColor = host.status === 'changed' || host.status === 'success'
|
||
? 'bg-green-600 hover:bg-green-500'
|
||
: host.status === 'failed' || host.status === 'unreachable'
|
||
? 'bg-red-600 hover:bg-red-500'
|
||
: 'bg-gray-600 hover:bg-gray-500';
|
||
const statusIcon = host.status === 'changed' || host.status === 'success'
|
||
? 'fa-check'
|
||
: host.status === 'failed' || host.status === 'unreachable'
|
||
? 'fa-times'
|
||
: 'fa-question';
|
||
|
||
return `
|
||
<button type="button"
|
||
onclick="dashboard.switchHostTab(${index})"
|
||
data-host-tab="${index}"
|
||
class="host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all ${index === 0 ? statusColor + ' text-white' : 'bg-gray-700/50 text-gray-400 hover:text-white'}">
|
||
<i class="fas ${statusIcon} text-[10px]"></i>
|
||
<span class="truncate max-w-[120px]">${this.escapeHtml(host.hostname)}</span>
|
||
</button>
|
||
`;
|
||
}).join('');
|
||
|
||
// Générer le compteur d'hôtes
|
||
const successCount = hostOutputs.filter(h => h.status === 'changed' || h.status === 'success').length;
|
||
const failedCount = hostOutputs.filter(h => h.status === 'failed' || h.status === 'unreachable').length;
|
||
|
||
stdoutSection.innerHTML = `
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">
|
||
<i class="fas fa-terminal mr-1"></i>Sortie par hôte
|
||
</span>
|
||
<span class="text-xs text-gray-500">
|
||
(${hostOutputs.length} hôtes:
|
||
<span class="text-green-400">${successCount} OK</span>
|
||
${failedCount > 0 ? `<span class="text-red-400">, ${failedCount} échec</span>` : ''})
|
||
</span>
|
||
</div>
|
||
<button type="button" onclick="dashboard.showAllHostsOutput()"
|
||
class="text-xs text-purple-400 hover:text-purple-300 transition-colors">
|
||
<i class="fas fa-list mr-1"></i>Voir tout
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Onglets des hôtes -->
|
||
<div class="flex flex-wrap gap-1 mb-0 border-b border-gray-700 pb-0">
|
||
${tabsHtml}
|
||
</div>
|
||
|
||
<!-- Contenu de l'onglet actif -->
|
||
<pre id="adhoc-stdout" class="text-sm bg-black/50 p-3 rounded-b-lg rounded-tr-lg overflow-auto font-mono text-gray-300 leading-relaxed border border-gray-800 border-t-0" style="max-height: 280px;">${this.formatAnsibleOutput(hostOutputs[0].output, isSuccess)}</pre>
|
||
`;
|
||
}
|
||
|
||
switchHostTab(index) {
|
||
if (!this.currentHostOutputs || !this.currentHostOutputs[index]) return;
|
||
|
||
const hostOutput = this.currentHostOutputs[index];
|
||
const stdoutPre = document.getElementById('adhoc-stdout');
|
||
const tabs = document.querySelectorAll('.host-tab');
|
||
|
||
// Mettre à jour l'onglet actif
|
||
tabs.forEach((tab, i) => {
|
||
if (i === index) {
|
||
const host = this.currentHostOutputs[i];
|
||
const statusColor = host.status === 'changed' || host.status === 'success'
|
||
? 'bg-green-600'
|
||
: host.status === 'failed' || host.status === 'unreachable'
|
||
? 'bg-red-600'
|
||
: 'bg-gray-600';
|
||
tab.className = `host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all ${statusColor} text-white`;
|
||
} else {
|
||
tab.className = 'host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all bg-gray-700/50 text-gray-400 hover:text-white';
|
||
}
|
||
});
|
||
|
||
// Afficher le contenu
|
||
if (stdoutPre) {
|
||
stdoutPre.innerHTML = this.formatAnsibleOutput(hostOutput.output, hostOutput.status === 'changed' || hostOutput.status === 'success');
|
||
}
|
||
}
|
||
|
||
showAllHostsOutput() {
|
||
if (!this.currentHostOutputs) return;
|
||
|
||
const stdoutPre = document.getElementById('adhoc-stdout');
|
||
if (stdoutPre) {
|
||
const allOutput = this.currentHostOutputs.map(h => h.output).join('\n\n');
|
||
stdoutPre.innerHTML = this.formatAnsibleOutput(allOutput, true);
|
||
}
|
||
|
||
// Désélectionner tous les onglets
|
||
document.querySelectorAll('.host-tab').forEach(tab => {
|
||
tab.className = 'host-tab flex items-center gap-2 px-3 py-1.5 rounded-t-lg text-xs font-medium transition-all bg-gray-700/50 text-gray-400 hover:text-white';
|
||
});
|
||
}
|
||
|
||
renderLogs() {
|
||
const container = document.getElementById('logs-container');
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
|
||
if (this.logs.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="text-center text-gray-500 py-4">
|
||
<p>Aucun log disponible</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
this.logs.forEach(log => {
|
||
const levelColor = this.getLogLevelColor(log.level);
|
||
// Formater le timestamp depuis l'API (format ISO)
|
||
const timestamp = log.timestamp
|
||
? new Date(log.timestamp).toLocaleString('fr-FR')
|
||
: '--';
|
||
|
||
const logEntry = document.createElement('div');
|
||
logEntry.className = 'log-entry';
|
||
logEntry.innerHTML = `
|
||
<div class="flex items-start space-x-3">
|
||
<span class="text-gray-500 text-xs whitespace-nowrap">${timestamp}</span>
|
||
<span class="px-2 py-1 text-xs rounded ${levelColor}">${log.level}</span>
|
||
<span class="text-gray-300 text-sm">${log.message}</span>
|
||
${log.host ? `<span class="text-xs text-purple-400">[${log.host}]</span>` : ''}
|
||
</div>
|
||
`;
|
||
container.appendChild(logEntry);
|
||
});
|
||
|
||
// Auto-scroll to bottom
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
getStatusBadge(status) {
|
||
const badges = {
|
||
'completed': '<span class="px-2 py-1 bg-green-600 text-xs rounded">Terminé</span>',
|
||
'running': '<span class="px-2 py-1 bg-blue-600 text-xs rounded">En cours</span>',
|
||
'pending': '<span class="px-2 py-1 bg-yellow-600 text-xs rounded">En attente</span>',
|
||
'failed': '<span class="px-2 py-1 bg-red-600 text-xs rounded">Échoué</span>'
|
||
};
|
||
return badges[status] || badges['pending'];
|
||
}
|
||
|
||
getLogLevelColor(level) {
|
||
const colors = {
|
||
'INFO': 'bg-blue-600 text-white',
|
||
'WARN': 'bg-yellow-600 text-white',
|
||
'ERROR': 'bg-red-600 text-white',
|
||
'DEBUG': 'bg-gray-600 text-white'
|
||
};
|
||
return colors[level] || colors['INFO'];
|
||
}
|
||
|
||
startAnimations() {
|
||
// Animate metrics on load
|
||
anime({
|
||
targets: '.metric-card',
|
||
translateY: [50, 0],
|
||
opacity: [0, 1],
|
||
delay: anime.stagger(200),
|
||
duration: 800,
|
||
easing: 'easeOutExpo'
|
||
});
|
||
|
||
// Animate host cards
|
||
anime({
|
||
targets: '.host-card',
|
||
translateX: [-30, 0],
|
||
opacity: [0, 1],
|
||
delay: anime.stagger(100),
|
||
duration: 600,
|
||
easing: 'easeOutExpo'
|
||
});
|
||
|
||
// Floating animation for hero elements
|
||
anime({
|
||
targets: '.animate-float',
|
||
translateY: [-10, 10],
|
||
duration: 3000,
|
||
direction: 'alternate',
|
||
loop: true,
|
||
easing: 'easeInOutSine'
|
||
});
|
||
}
|
||
|
||
setupScrollAnimations() {
|
||
const observerOptions = {
|
||
threshold: 0.1,
|
||
rootMargin: '0px 0px -50px 0px'
|
||
};
|
||
|
||
const observer = new IntersectionObserver((entries) => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
entry.target.classList.add('visible');
|
||
}
|
||
});
|
||
}, observerOptions);
|
||
|
||
document.querySelectorAll('.fade-in').forEach(el => {
|
||
observer.observe(el);
|
||
});
|
||
}
|
||
|
||
// Les mises à jour en temps réel sont gérées par WebSocket maintenant
|
||
|
||
// Public methods for UI interactions
|
||
showQuickActions() {
|
||
// Construire la liste des groupes Ansible pour le sélecteur
|
||
const groupOptions = this.ansibleGroups.map(g =>
|
||
`<option value="${g}">${g}</option>`
|
||
).join('');
|
||
|
||
this.showModal('Actions Rapides - Ansible', `
|
||
<div class="space-y-4">
|
||
<div class="mb-4">
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Cible (groupe ou hôte)</label>
|
||
<select id="ansible-target" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg">
|
||
<option value="all">Tous les hôtes</option>
|
||
${groupOptions}
|
||
</select>
|
||
</div>
|
||
<button class="w-full p-4 bg-gradient-to-r from-green-600 to-green-700 rounded-lg text-left hover:from-green-500 hover:to-green-600 transition-all" onclick="dashboard.executeAnsibleTask('upgrade')">
|
||
<i class="fas fa-arrow-up mr-3"></i>
|
||
Mettre à jour les systèmes (vm-upgrade.yml)
|
||
</button>
|
||
<button class="w-full p-4 bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg text-left hover:from-blue-500 hover:to-blue-600 transition-all" onclick="dashboard.executeAnsibleTask('reboot')">
|
||
<i class="fas fa-redo mr-3"></i>
|
||
Redémarrer les hôtes (vm-reboot.yml)
|
||
</button>
|
||
<button class="w-full p-4 bg-gradient-to-r from-purple-600 to-purple-700 rounded-lg text-left hover:from-purple-500 hover:to-purple-600 transition-all" onclick="dashboard.executeAnsibleTask('health-check')">
|
||
<i class="fas fa-heartbeat mr-3"></i>
|
||
Vérifier la santé (health-check.yml)
|
||
</button>
|
||
<button class="w-full p-4 bg-gradient-to-r from-orange-600 to-orange-700 rounded-lg text-left hover:from-orange-500 hover:to-orange-600 transition-all" onclick="dashboard.executeAnsibleTask('backup')">
|
||
<i class="fas fa-save mr-3"></i>
|
||
Sauvegarder la config (backup-config.yml)
|
||
</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async executeAnsibleTask(action) {
|
||
const targetSelect = document.getElementById('ansible-target');
|
||
const target = targetSelect ? targetSelect.value : 'all';
|
||
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
// Appeler l'API pour créer une tâche Ansible
|
||
const result = await this.apiCall('/api/tasks', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
action: action,
|
||
group: target,
|
||
dry_run: false
|
||
})
|
||
});
|
||
|
||
this.hideLoading();
|
||
this.showNotification(`Tâche '${result.name}' lancée sur ${target}`, 'success');
|
||
|
||
// Recharger les données
|
||
await this.loadAllData();
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// Legacy function for backward compatibility
|
||
executeTask(taskType) {
|
||
// Mapper les anciens types vers les nouvelles actions
|
||
const actionMap = {
|
||
'upgrade-all': 'upgrade',
|
||
'reboot-all': 'reboot',
|
||
'health-check': 'health-check',
|
||
'backup': 'backup'
|
||
};
|
||
const action = actionMap[taskType] || taskType;
|
||
this.executeAnsibleTask(action);
|
||
}
|
||
|
||
async executeHostAction(action, hostName) {
|
||
// hostName peut être un ID (ancien système) ou un nom d'hôte (nouveau système Ansible)
|
||
let targetHost = hostName;
|
||
|
||
// Si c'est un nombre, chercher par ID (compatibilité)
|
||
if (typeof hostName === 'number') {
|
||
const host = this.hosts.find(h => h.id === hostName);
|
||
if (!host) {
|
||
this.showNotification('Hôte non trouvé', 'error');
|
||
return;
|
||
}
|
||
targetHost = host.name;
|
||
}
|
||
|
||
this.showLoading();
|
||
|
||
try {
|
||
// Mapper les actions vers les playbooks Ansible
|
||
const actionMap = {
|
||
'update': 'upgrade',
|
||
'upgrade': 'upgrade',
|
||
'reboot': 'reboot',
|
||
'connect': 'health-check',
|
||
'health-check': 'health-check',
|
||
'backup': 'backup'
|
||
};
|
||
|
||
const ansibleAction = actionMap[action];
|
||
if (ansibleAction) {
|
||
const result = await this.apiCall('/api/tasks', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
action: ansibleAction,
|
||
host: targetHost,
|
||
dry_run: false
|
||
})
|
||
});
|
||
|
||
this.hideLoading();
|
||
this.showNotification(`Tâche '${result.name}' lancée sur ${targetHost}`, 'success');
|
||
await this.loadAllData();
|
||
} else {
|
||
this.hideLoading();
|
||
this.showNotification(`Action '${action}' non supportée`, 'warning');
|
||
}
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// Redirige vers la modale avancée d'ajout d'hôte (avec groupes env/role et écriture dans hosts.yml)
|
||
addHost() {
|
||
this.showAddHostModal();
|
||
}
|
||
|
||
showBootstrapModal(hostName, hostIp) {
|
||
this.showModal(`Bootstrap SSH - ${hostName}`, `
|
||
<form onsubmit="dashboard.executeBootstrap(event)" class="space-y-4">
|
||
<div class="p-4 bg-yellow-900/30 border border-yellow-600 rounded-lg mb-4">
|
||
<div class="flex items-start space-x-3">
|
||
<i class="fas fa-exclamation-triangle text-yellow-500 mt-1"></i>
|
||
<div class="text-sm text-yellow-200">
|
||
<p class="font-semibold mb-1">Cette opération va :</p>
|
||
<ul class="list-disc list-inside text-xs space-y-1">
|
||
<li>Créer l'utilisateur d'automatisation</li>
|
||
<li>Configurer l'authentification SSH par clé</li>
|
||
<li>Installer et configurer sudo</li>
|
||
<li>Installer Python3 (requis par Ansible)</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<input type="hidden" name="host" value="${hostIp || hostName}">
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">Hôte cible</label>
|
||
<input type="text" value="${hostIp || hostName}" disabled
|
||
class="w-full p-3 bg-gray-800 border border-gray-600 rounded-lg text-gray-400">
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-key mr-1"></i>Mot de passe root
|
||
</label>
|
||
<input type="password" name="root_password"
|
||
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-yellow-500 focus:outline-none"
|
||
placeholder="Mot de passe root de l'hôte" required>
|
||
<p class="text-xs text-gray-500 mt-1">Utilisé uniquement pour la configuration initiale</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-user mr-1"></i>Utilisateur d'automatisation
|
||
</label>
|
||
<input type="text" name="automation_user" value="automation"
|
||
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-yellow-500 focus:outline-none"
|
||
placeholder="automation">
|
||
</div>
|
||
|
||
<div class="flex space-x-3 pt-4">
|
||
<button type="submit" class="flex-1 btn-primary bg-yellow-600 hover:bg-yellow-500">
|
||
<i class="fas fa-rocket mr-2"></i>
|
||
Lancer le Bootstrap
|
||
</button>
|
||
<button type="button" onclick="dashboard.closeModal()"
|
||
class="flex-1 px-4 py-2 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</form>
|
||
`);
|
||
}
|
||
|
||
async executeBootstrap(event) {
|
||
event.preventDefault();
|
||
const formData = new FormData(event.target);
|
||
|
||
const host = formData.get('host');
|
||
const rootPassword = formData.get('root_password');
|
||
const automationUser = formData.get('automation_user') || 'automation';
|
||
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
const result = await this.apiCall('/api/ansible/bootstrap', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
host: host,
|
||
root_password: rootPassword,
|
||
automation_user: automationUser
|
||
})
|
||
});
|
||
|
||
this.hideLoading();
|
||
|
||
// Afficher le résultat dans un modal
|
||
this.showModal('Bootstrap Réussi', `
|
||
<div class="space-y-4">
|
||
<div class="p-4 bg-green-900/30 border border-green-600 rounded-lg">
|
||
<div class="flex items-center space-x-3">
|
||
<i class="fas fa-check-circle text-green-500 text-2xl"></i>
|
||
<div>
|
||
<p class="font-semibold text-green-200">Configuration terminée!</p>
|
||
<p class="text-sm text-green-300">L'hôte ${host} est prêt pour Ansible</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-4 bg-gray-800 rounded-lg">
|
||
<h4 class="font-semibold mb-2 text-sm">Détails</h4>
|
||
<pre class="text-xs text-gray-400 max-h-60 overflow-auto whitespace-pre-wrap">${result.stdout || 'Pas de sortie'}</pre>
|
||
</div>
|
||
|
||
<button onclick="dashboard.closeModal()" class="w-full btn-primary">
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
`);
|
||
|
||
this.showNotification(`Bootstrap réussi pour ${host}`, 'success');
|
||
await this.loadAllData();
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
|
||
// Extraire les détails de l'erreur
|
||
let errorDetail = error.message;
|
||
let stdout = '';
|
||
let stderr = '';
|
||
|
||
if (error.detail && typeof error.detail === 'object') {
|
||
stdout = error.detail.stdout || '';
|
||
stderr = error.detail.stderr || '';
|
||
}
|
||
|
||
this.showModal('Erreur Bootstrap', `
|
||
<div class="space-y-4">
|
||
<div class="p-4 bg-red-900/30 border border-red-600 rounded-lg">
|
||
<div class="flex items-center space-x-3">
|
||
<i class="fas fa-times-circle text-red-500 text-2xl"></i>
|
||
<div>
|
||
<p class="font-semibold text-red-200">Bootstrap échoué</p>
|
||
<p class="text-sm text-red-300">${errorDetail}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${stderr ? `
|
||
<div class="p-4 bg-gray-800 rounded-lg">
|
||
<h4 class="font-semibold mb-2 text-sm text-red-400">Erreur</h4>
|
||
<pre class="text-xs text-red-300 max-h-40 overflow-auto whitespace-pre-wrap">${stderr}</pre>
|
||
</div>
|
||
` : ''}
|
||
|
||
${stdout ? `
|
||
<div class="p-4 bg-gray-800 rounded-lg">
|
||
<h4 class="font-semibold mb-2 text-sm">Sortie</h4>
|
||
<pre class="text-xs text-gray-400 max-h-40 overflow-auto whitespace-pre-wrap">${stdout}</pre>
|
||
</div>
|
||
` : ''}
|
||
|
||
<button onclick="dashboard.closeModal()" class="w-full btn-primary bg-red-600 hover:bg-red-500">
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
`);
|
||
}
|
||
}
|
||
|
||
manageHost(hostNameOrId) {
|
||
// Support both host name and ID
|
||
let host;
|
||
if (typeof hostNameOrId === 'number') {
|
||
host = this.hosts.find(h => h.id === hostNameOrId);
|
||
} else {
|
||
host = this.hosts.find(h => h.name === hostNameOrId);
|
||
}
|
||
if (!host) return;
|
||
|
||
const lastSeen = host.last_seen
|
||
? new Date(host.last_seen).toLocaleString('fr-FR')
|
||
: 'Jamais vérifié';
|
||
|
||
// Bootstrap status
|
||
const bootstrapOk = host.bootstrap_ok || false;
|
||
const bootstrapDate = host.bootstrap_date
|
||
? new Date(host.bootstrap_date).toLocaleString('fr-FR')
|
||
: null;
|
||
const bootstrapStatusHtml = bootstrapOk
|
||
? `<div class="flex items-center text-green-400">
|
||
<i class="fas fa-check-circle mr-2"></i>
|
||
<span>Ansible Ready</span>
|
||
<span class="text-xs text-gray-500 ml-2">(${bootstrapDate || 'N/A'})</span>
|
||
</div>`
|
||
: `<div class="flex items-center text-yellow-400">
|
||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||
<span>Non configuré - Bootstrap requis</span>
|
||
</div>`;
|
||
|
||
this.showModal(`Gérer ${host.name}`, `
|
||
<div class="space-y-4">
|
||
<div class="p-4 bg-gray-800 rounded-lg">
|
||
<h4 class="font-semibold mb-3">Informations de l'hôte</h4>
|
||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||
<p class="text-gray-400">Nom:</p>
|
||
<p class="text-gray-200">${host.name}</p>
|
||
<p class="text-gray-400">IP:</p>
|
||
<p class="text-gray-200">${host.ip}</p>
|
||
<p class="text-gray-400">OS:</p>
|
||
<p class="text-gray-200">${host.os}</p>
|
||
<p class="text-gray-400">Statut:</p>
|
||
<p class="text-gray-200">${host.status}</p>
|
||
<p class="text-gray-400">Dernière connexion:</p>
|
||
<p class="text-gray-200">${lastSeen}</p>
|
||
</div>
|
||
</div>
|
||
<div class="p-4 ${bootstrapOk ? 'bg-green-900/20 border border-green-600/30' : 'bg-yellow-900/20 border border-yellow-600/30'} rounded-lg">
|
||
<h4 class="font-semibold mb-2 text-sm">Statut Bootstrap Ansible</h4>
|
||
${bootstrapStatusHtml}
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<button class="p-3 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors" onclick="dashboard.executeHostAction('connect', '${host.name}')">
|
||
<i class="fas fa-heartbeat mr-2"></i>Health Check
|
||
</button>
|
||
<button class="p-3 bg-green-600 rounded-lg hover:bg-green-500 transition-colors" onclick="dashboard.executeHostAction('update', '${host.name}')">
|
||
<i class="fas fa-arrow-up mr-2"></i>Upgrade
|
||
</button>
|
||
<button class="p-3 bg-orange-600 rounded-lg hover:bg-orange-500 transition-colors" onclick="dashboard.executeHostAction('reboot', '${host.name}')">
|
||
<i class="fas fa-redo mr-2"></i>Reboot
|
||
</button>
|
||
<button class="p-3 bg-blue-600 rounded-lg hover:bg-blue-500 transition-colors" onclick="dashboard.executeHostAction('backup', '${host.name}')">
|
||
<i class="fas fa-save mr-2"></i>Backup
|
||
</button>
|
||
<button class="p-3 ${bootstrapOk ? 'bg-gray-600' : 'bg-yellow-600 animate-pulse'} rounded-lg hover:bg-yellow-500 transition-colors col-span-2" onclick="dashboard.showBootstrapModal('${host.name}', '${host.ip}')">
|
||
<i class="fas fa-tools mr-2"></i>${bootstrapOk ? 'Re-Bootstrap SSH' : 'Bootstrap SSH (Requis)'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
removeHost(hostId) {
|
||
if (confirm('Êtes-vous sûr de vouloir supprimer cet hôte?')) {
|
||
this.hosts = this.hosts.filter(h => h.id !== hostId);
|
||
this.renderHosts();
|
||
this.closeModal();
|
||
this.showNotification('Hôte supprimé avec succès!', 'success');
|
||
}
|
||
}
|
||
|
||
addTaskToList(taskType) {
|
||
const taskNames = {
|
||
'upgrade-all': 'Mise à jour système',
|
||
'reboot-all': 'Redémarrage système',
|
||
'health-check': 'Vérification de santé',
|
||
'backup': 'Sauvegarde'
|
||
};
|
||
|
||
const newTask = {
|
||
id: Math.max(...this.tasks.map(t => t.id)) + 1,
|
||
name: taskNames[taskType] || 'Tâche inconnue',
|
||
host: 'Multiple',
|
||
status: 'running',
|
||
progress: 0,
|
||
startTime: new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
|
||
duration: '0s'
|
||
};
|
||
|
||
this.tasks.unshift(newTask);
|
||
this.renderTasks();
|
||
|
||
// Simulate task completion
|
||
setTimeout(() => {
|
||
const task = this.tasks.find(t => t.id === newTask.id);
|
||
if (task) {
|
||
task.status = 'completed';
|
||
task.progress = 100;
|
||
this.renderTasks();
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
stopTask(taskId) {
|
||
const task = this.tasks.find(t => t.id === taskId);
|
||
if (task && task.status === 'running') {
|
||
task.status = 'failed';
|
||
task.progress = 0;
|
||
this.renderTasks();
|
||
this.showNotification('Tâche arrêtée', 'warning');
|
||
}
|
||
}
|
||
|
||
viewTaskDetails(taskId) {
|
||
const task = this.tasks.find(t => t.id === taskId);
|
||
if (!task) return;
|
||
|
||
this.showModal(`Détails de la tâche`, `
|
||
<div class="space-y-4">
|
||
<div class="p-4 bg-gray-800 rounded-lg">
|
||
<h4 class="font-semibold mb-2">${task.name}</h4>
|
||
<p class="text-sm text-gray-300">Hôte: ${task.host}</p>
|
||
<p class="text-sm text-gray-300">Statut: ${this.getStatusBadge(task.status)}</p>
|
||
<p class="text-sm text-gray-300">Progression: ${task.progress}%</p>
|
||
<p class="text-sm text-gray-300">Durée: ${task.duration}</p>
|
||
</div>
|
||
<div class="p-4 bg-gray-800 rounded-lg">
|
||
<h5 class="font-medium mb-2">Logs de la tâche</h5>
|
||
<div class="text-xs text-gray-400 space-y-1 max-h-32 overflow-y-auto">
|
||
<p>• Démarrage de la tâche...</p>
|
||
<p>• Connexion SSH établie</p>
|
||
<p>• Exécution des commandes...</p>
|
||
<p>• Tâche terminée avec succès</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
refreshTasks() {
|
||
this.showLoading();
|
||
setTimeout(() => {
|
||
this.hideLoading();
|
||
this.showNotification('Tâches rafraîchies', 'success');
|
||
}, 1000);
|
||
}
|
||
|
||
clearLogs() {
|
||
if (confirm('Êtes-vous sûr de vouloir effacer tous les logs?')) {
|
||
this.logs = [];
|
||
this.renderLogs();
|
||
this.showNotification('Logs effacés avec succès!', 'success');
|
||
}
|
||
}
|
||
|
||
exportLogs() {
|
||
const logText = this.logs.map(log => `${log.timestamp} [${log.level}] ${log.message}`).join('\n');
|
||
const blob = new Blob([logText], { type: 'text/plain' });
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `homelab-logs-${new Date().toISOString().slice(0, 10)}.txt`;
|
||
a.click();
|
||
|
||
URL.revokeObjectURL(url);
|
||
this.showNotification('Logs exportés avec succès!', 'success');
|
||
}
|
||
|
||
// ===== GESTION DES PLAYBOOKS =====
|
||
|
||
renderPlaybooks() {
|
||
const container = document.getElementById('playbooks-list');
|
||
if (!container) return;
|
||
|
||
// Filtrer les playbooks
|
||
let filteredPlaybooks = this.playbooks;
|
||
|
||
// Filtre par catégorie
|
||
if (this.currentPlaybookCategoryFilter && this.currentPlaybookCategoryFilter !== 'all') {
|
||
filteredPlaybooks = filteredPlaybooks.filter(pb =>
|
||
(pb.category || 'general').toLowerCase() === this.currentPlaybookCategoryFilter.toLowerCase()
|
||
);
|
||
}
|
||
|
||
// Filtre par recherche
|
||
if (this.currentPlaybookSearch) {
|
||
const search = this.currentPlaybookSearch.toLowerCase();
|
||
filteredPlaybooks = filteredPlaybooks.filter(pb =>
|
||
pb.name.toLowerCase().includes(search) ||
|
||
pb.filename.toLowerCase().includes(search) ||
|
||
(pb.description || '').toLowerCase().includes(search)
|
||
);
|
||
}
|
||
|
||
// Mettre à jour le compteur
|
||
const countEl = document.getElementById('playbooks-count');
|
||
if (countEl) {
|
||
countEl.innerHTML = `<i class="fas fa-file-code mr-1"></i>${filteredPlaybooks.length} playbook${filteredPlaybooks.length > 1 ? 's' : ''}`;
|
||
}
|
||
|
||
if (filteredPlaybooks.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="text-center py-12 text-gray-500">
|
||
<i class="fas fa-file-code text-4xl mb-4 opacity-50"></i>
|
||
<p class="mb-2">Aucun playbook trouvé</p>
|
||
${this.currentPlaybookSearch || this.currentPlaybookCategoryFilter !== 'all'
|
||
? '<p class="text-sm">Essayez de modifier vos filtres</p>'
|
||
: '<button onclick="dashboard.showCreatePlaybookModal()" class="mt-4 px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors"><i class="fas fa-plus mr-2"></i>Créer un playbook</button>'}
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = filteredPlaybooks.map(pb => this.createPlaybookCardHTML(pb)).join('');
|
||
|
||
// Mettre à jour dynamiquement les boutons de filtre de catégorie en fonction des playbooks présents
|
||
this.updatePlaybookCategoryFilters();
|
||
}
|
||
|
||
updatePlaybookCategoryFilters() {
|
||
const container = document.getElementById('playbook-category-filters');
|
||
if (!container) return;
|
||
|
||
// Construire la liste des catégories présentes à partir des playbooks
|
||
const categorySet = new Set();
|
||
(this.playbooks || []).forEach(pb => {
|
||
const cat = (pb.category || 'general').toLowerCase();
|
||
categorySet.add(cat);
|
||
});
|
||
|
||
const categories = Array.from(categorySet).sort((a, b) => a.localeCompare(b, 'fr'));
|
||
|
||
const buttonsHtml = [
|
||
`<button onclick="dashboard.filterPlaybooksByCategory('all')"
|
||
class="playbook-filter-btn px-3 py-1.5 text-xs rounded-lg transition-colors ${!this.currentPlaybookCategoryFilter || this.currentPlaybookCategoryFilter === 'all' ? 'bg-purple-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}" data-category="all">
|
||
Tous
|
||
</button>`,
|
||
...categories.map(cat => {
|
||
const label = this.getCategoryLabel(cat);
|
||
const icon = this.getPlaybookCategoryIcon(cat);
|
||
const isActive = this.currentPlaybookCategoryFilter && this.currentPlaybookCategoryFilter.toLowerCase() === cat;
|
||
const activeClasses = isActive ? 'bg-purple-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600';
|
||
return `
|
||
<button onclick="dashboard.filterPlaybooksByCategory('${this.escapeHtml(cat)}')"
|
||
class="playbook-filter-btn px-3 py-1.5 text-xs rounded-lg transition-colors ${activeClasses}"
|
||
data-category="${this.escapeHtml(cat)}">
|
||
${icon ? `<i class="fas ${icon} mr-1"></i>` : ''}${this.escapeHtml(label)}
|
||
</button>
|
||
`;
|
||
})
|
||
].join('');
|
||
|
||
container.innerHTML = `
|
||
<span class="text-xs text-gray-500 mr-2"><i class="fas fa-filter mr-1"></i>Catégorie:</span>
|
||
${buttonsHtml}
|
||
`;
|
||
}
|
||
|
||
createPlaybookCardHTML(playbook) {
|
||
const category = (playbook.category || 'general').toLowerCase();
|
||
const categoryClass = `playbook-category-${category}`;
|
||
const categoryLabel = this.getCategoryLabel(category);
|
||
|
||
// Calculer la taille formatée
|
||
const sizeKb = playbook.size ? (playbook.size / 1024).toFixed(1) : '?';
|
||
|
||
// Calculer le temps relatif
|
||
const modifiedAgo = playbook.modified ? this.getRelativeTime(playbook.modified) : 'Date inconnue';
|
||
|
||
return `
|
||
<div class="playbook-card group" data-playbook="${this.escapeHtml(playbook.filename)}">
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-3 mb-2">
|
||
<i class="fas fa-file-code text-purple-400"></i>
|
||
<h4 class="font-medium text-white truncate">${this.escapeHtml(playbook.filename)}</h4>
|
||
<span class="playbook-category-badge ${categoryClass}">${categoryLabel}</span>
|
||
</div>
|
||
${playbook.description ? `<p class="text-sm text-gray-400 mb-2 ml-7">${this.escapeHtml(playbook.description)}</p>` : ''}
|
||
<div class="flex items-center gap-4 text-xs text-gray-500 ml-7">
|
||
<span class="time-ago"><i class="fas fa-clock mr-1"></i>${modifiedAgo}</span>
|
||
<span class="file-size-badge">${sizeKb} KB</span>
|
||
${playbook.subcategory ? `<span class="text-purple-400">${playbook.subcategory}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button class="playbook-action-btn edit" onclick="event.stopPropagation(); dashboard.editPlaybook('${this.escapeHtml(playbook.filename)}')" title="Modifier">
|
||
<i class="fas fa-edit"></i>
|
||
</button>
|
||
<button class="playbook-action-btn run" onclick="event.stopPropagation(); dashboard.runPlaybook('${this.escapeHtml(playbook.filename)}')" title="Exécuter">
|
||
<i class="fas fa-play"></i>
|
||
</button>
|
||
<button class="playbook-action-btn" style="background: rgba(124, 58, 237, 0.2); color: #a78bfa;" onclick="event.stopPropagation(); dashboard.showCreateScheduleModal('${this.escapeHtml(playbook.filename)}')" title="Planifier">
|
||
<i class="fas fa-calendar-alt"></i>
|
||
</button>
|
||
<button class="playbook-action-btn delete" onclick="event.stopPropagation(); dashboard.confirmDeletePlaybook('${this.escapeHtml(playbook.filename)}')" title="Supprimer">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
getCategoryLabel(category) {
|
||
const labels = {
|
||
'maintenance': 'Maintenance',
|
||
'deploy': 'Deploy',
|
||
'backup': 'Backup',
|
||
'monitoring': 'Monitoring',
|
||
'system': 'System',
|
||
'general': 'Général',
|
||
'testing': 'Testing'
|
||
};
|
||
return labels[category] || category;
|
||
}
|
||
|
||
getPlaybookCategoryIcon(category) {
|
||
const icons = {
|
||
'maintenance': 'fa-wrench',
|
||
'deploy': 'fa-rocket',
|
||
'backup': 'fa-save',
|
||
'monitoring': 'fa-heartbeat',
|
||
'system': 'fa-cogs',
|
||
'testing': 'fa-flask'
|
||
};
|
||
return icons[category] || null;
|
||
}
|
||
|
||
getRelativeTime(dateString) {
|
||
if (!dateString) return 'Date inconnue';
|
||
const date = new Date(dateString);
|
||
const now = new Date();
|
||
const diffMs = now - date;
|
||
const diffSec = Math.floor(diffMs / 1000);
|
||
const diffMin = Math.floor(diffSec / 60);
|
||
const diffHour = Math.floor(diffMin / 60);
|
||
const diffDay = Math.floor(diffHour / 24);
|
||
|
||
if (diffSec < 60) return 'À l\'instant';
|
||
if (diffMin < 60) return `il y a ${diffMin} min`;
|
||
if (diffHour < 24) return `il y a ${diffHour}h`;
|
||
if (diffDay === 1) return 'Hier';
|
||
if (diffDay < 7) return `il y a ${diffDay} jours`;
|
||
if (diffDay < 30) return `il y a ${Math.floor(diffDay / 7)} sem.`;
|
||
return date.toLocaleDateString('fr-FR');
|
||
}
|
||
|
||
filterPlaybooks(searchText) {
|
||
this.currentPlaybookSearch = searchText;
|
||
this.renderPlaybooks();
|
||
}
|
||
|
||
filterPlaybooksByCategory(category) {
|
||
this.currentPlaybookCategoryFilter = category;
|
||
|
||
// Mettre à jour les boutons de filtre
|
||
document.querySelectorAll('.playbook-filter-btn').forEach(btn => {
|
||
const btnCategory = btn.dataset.category;
|
||
if (btnCategory === category) {
|
||
btn.classList.remove('bg-gray-700', 'text-gray-300');
|
||
btn.classList.add('bg-purple-600', 'text-white');
|
||
} else {
|
||
btn.classList.remove('bg-purple-600', 'text-white');
|
||
btn.classList.add('bg-gray-700', 'text-gray-300');
|
||
}
|
||
});
|
||
|
||
this.renderPlaybooks();
|
||
}
|
||
|
||
async refreshPlaybooks() {
|
||
this.showLoading();
|
||
try {
|
||
const playbooksData = await this.apiCall('/api/ansible/playbooks');
|
||
this.playbooks = playbooksData.playbooks || [];
|
||
this.playbookCategories = playbooksData.categories || {};
|
||
this.renderPlaybooks();
|
||
this.hideLoading();
|
||
this.showNotification('Playbooks rechargés', 'success');
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async editPlaybook(filename) {
|
||
this.showLoading();
|
||
try {
|
||
const result = await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}/content`);
|
||
this.hideLoading();
|
||
this.showPlaybookEditor(filename, result.content, false);
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur chargement playbook: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
showCreatePlaybookModal() {
|
||
const defaultContent = `---
|
||
# Nouveau Playbook Ansible
|
||
# Documentation: https://docs.ansible.com/ansible/latest/playbook_guide/
|
||
|
||
- name: Mon nouveau playbook
|
||
hosts: all
|
||
become: yes
|
||
vars:
|
||
category: general
|
||
subcategory: other
|
||
|
||
tasks:
|
||
- name: Exemple de tâche
|
||
ansible.builtin.debug:
|
||
msg: "Hello from Ansible!"
|
||
`;
|
||
|
||
this.showModal('Créer un Playbook', `
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-file-code mr-1"></i>Nom du fichier <span class="text-red-400">*</span>
|
||
</label>
|
||
<div class="flex items-center">
|
||
<input type="text" id="new-playbook-name"
|
||
class="flex-1 p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none"
|
||
placeholder="mon-playbook" pattern="[a-zA-Z0-9_-]+" required>
|
||
<span class="px-3 py-3 bg-gray-600 border border-gray-500 border-l-0 rounded-r-lg text-gray-400">.yml</span>
|
||
</div>
|
||
<p class="text-xs text-gray-500 mt-1">Lettres, chiffres, tirets et underscores uniquement</p>
|
||
</div>
|
||
|
||
<div class="flex space-x-3 pt-4">
|
||
<button onclick="dashboard.createNewPlaybook()" class="flex-1 btn-primary bg-purple-600 hover:bg-purple-500 py-3 rounded-lg font-medium">
|
||
<i class="fas fa-plus mr-2"></i>Créer et Éditer
|
||
</button>
|
||
<button onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async createNewPlaybook() {
|
||
const nameInput = document.getElementById('new-playbook-name');
|
||
const name = nameInput?.value.trim();
|
||
|
||
if (!name) {
|
||
this.showNotification('Veuillez saisir un nom de fichier', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||
this.showNotification('Nom invalide: utilisez uniquement lettres, chiffres, tirets et underscores', 'error');
|
||
return;
|
||
}
|
||
|
||
const filename = `${name}.yml`;
|
||
|
||
// Vérifier si le fichier existe déjà
|
||
const exists = this.playbooks.some(pb => pb.filename.toLowerCase() === filename.toLowerCase());
|
||
if (exists) {
|
||
this.showNotification(`Le playbook "${filename}" existe déjà`, 'error');
|
||
return;
|
||
}
|
||
|
||
const defaultContent = `---
|
||
# ${filename}
|
||
# Créé le ${new Date().toLocaleDateString('fr-FR')}
|
||
|
||
- name: ${name.replace(/-/g, ' ').replace(/_/g, ' ')}
|
||
hosts: all
|
||
become: yes
|
||
vars:
|
||
category: general
|
||
subcategory: other
|
||
|
||
tasks:
|
||
- name: Exemple de tâche
|
||
ansible.builtin.debug:
|
||
msg: "Playbook ${name} exécuté avec succès!"
|
||
`;
|
||
|
||
// On ouvre directement l'éditeur avec le contenu par défaut
|
||
this.showPlaybookEditor(filename, defaultContent, true);
|
||
}
|
||
|
||
showPlaybookEditor(filename, content, isNew = false) {
|
||
const title = isNew ? `Créer: ${filename}` : `Modifier: ${filename}`;
|
||
|
||
const modalContent = `
|
||
<div class="space-y-4">
|
||
<!-- Barre d'outils -->
|
||
<div class="editor-toolbar flex items-center justify-between rounded-t-lg -mx-8 -mt-8 mb-4 px-6 py-3">
|
||
<div class="flex items-center gap-3">
|
||
<i class="fas fa-file-code text-purple-400 text-xl"></i>
|
||
<span class="font-medium text-white">${this.escapeHtml(filename)}</span>
|
||
${isNew ? '<span class="px-2 py-0.5 bg-green-600/30 text-green-400 text-xs rounded">Nouveau</span>' : ''}
|
||
</div>
|
||
<div class="yaml-hint hidden sm:block">
|
||
<i class="fas fa-info-circle mr-1"></i>
|
||
Format YAML • Indentation: 2 espaces
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Éditeur de code -->
|
||
<div class="playbook-editor-container">
|
||
<textarea id="playbook-editor-content"
|
||
class="playbook-code-editor"
|
||
spellcheck="false"
|
||
wrap="off">${this.escapeHtml(content)}</textarea>
|
||
</div>
|
||
|
||
<!-- Pied de page avec validation -->
|
||
<div class="flex items-center justify-between pt-4 border-t border-gray-700">
|
||
<div id="yaml-validation-status" class="text-sm">
|
||
<i class="fas fa-check-circle text-green-400 mr-1"></i>
|
||
<span class="text-gray-400">YAML valide</span>
|
||
</div>
|
||
<div class="flex gap-3">
|
||
<button onclick="dashboard.closeModal()" class="px-4 py-2 bg-gray-600 rounded-lg hover:bg-gray-500 transition-colors">
|
||
<i class="fas fa-times mr-2"></i>Annuler
|
||
</button>
|
||
<button onclick="dashboard.savePlaybook('${this.escapeHtml(filename)}', ${isNew})"
|
||
class="px-4 py-2 bg-purple-600 rounded-lg hover:bg-purple-500 transition-colors shadow-lg shadow-purple-500/20">
|
||
<i class="fas fa-save mr-2"></i>Sauvegarder
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
this.showModal(title, modalContent);
|
||
|
||
// Setup de la validation YAML basique + support de la touche Tab
|
||
setTimeout(() => {
|
||
const textarea = document.getElementById('playbook-editor-content');
|
||
if (textarea) {
|
||
textarea.addEventListener('input', () => this.validateYamlContent(textarea.value));
|
||
textarea.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
|
||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||
}
|
||
});
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
validateYamlContent(content) {
|
||
const statusEl = document.getElementById('yaml-validation-status');
|
||
if (!statusEl) return;
|
||
|
||
// Validation basique du YAML
|
||
const errors = [];
|
||
const lines = content.split('\n');
|
||
|
||
lines.forEach((line, index) => {
|
||
// Vérifier les tabs (doit utiliser des espaces)
|
||
if (line.includes('\t')) {
|
||
errors.push(`Ligne ${index + 1}: Utilisez des espaces au lieu des tabs`);
|
||
}
|
||
// Vérifier l'indentation impaire
|
||
const leadingSpaces = line.match(/^(\s*)/)[1].length;
|
||
if (leadingSpaces % 2 !== 0 && line.trim()) {
|
||
errors.push(`Ligne ${index + 1}: Indentation impaire détectée`);
|
||
}
|
||
});
|
||
|
||
if (errors.length > 0) {
|
||
statusEl.innerHTML = `
|
||
<i class="fas fa-exclamation-triangle text-yellow-400 mr-1"></i>
|
||
<span class="text-yellow-400">${errors[0]}</span>
|
||
`;
|
||
} else {
|
||
statusEl.innerHTML = `
|
||
<i class="fas fa-check-circle text-green-400 mr-1"></i>
|
||
<span class="text-gray-400">YAML valide</span>
|
||
`;
|
||
}
|
||
}
|
||
|
||
async savePlaybook(filename, isNew = false) {
|
||
const textarea = document.getElementById('playbook-editor-content');
|
||
if (!textarea) return;
|
||
|
||
const content = textarea.value;
|
||
|
||
if (!content.trim()) {
|
||
this.showNotification('Le contenu ne peut pas être vide', 'warning');
|
||
return;
|
||
}
|
||
|
||
this.showLoading();
|
||
|
||
try {
|
||
const result = await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}/content`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ content: content })
|
||
});
|
||
|
||
this.hideLoading();
|
||
this.closeModal();
|
||
|
||
// Retirer la classe spéciale
|
||
const modalCard = document.querySelector('#modal .glass-card');
|
||
if (modalCard) {
|
||
modalCard.classList.remove('playbook-editor-modal');
|
||
}
|
||
|
||
this.showNotification(isNew ? `Playbook "${filename}" créé avec succès` : `Playbook "${filename}" sauvegardé`, 'success');
|
||
|
||
// Rafraîchir la liste
|
||
await this.refreshPlaybooks();
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur sauvegarde: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async runPlaybook(filename) {
|
||
// Ouvrir le modal d'exécution pour un playbook existant
|
||
const targetOptions = [
|
||
'<option value="all">Tous les hôtes (all)</option>',
|
||
...this.ansibleGroups.map(g => `<option value="${g}">${g}</option>`)
|
||
].join('');
|
||
|
||
this.showModal(`Exécuter: ${this.escapeHtml(filename)}`, `
|
||
<div class="space-y-4">
|
||
<div class="p-3 bg-purple-900/30 border border-purple-600 rounded-lg text-sm">
|
||
<i class="fas fa-play-circle text-purple-400 mr-2"></i>
|
||
Configurez les options d'exécution du playbook
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-bullseye mr-1"></i>Cible (hosts/groupe)
|
||
</label>
|
||
<select id="run-playbook-target" class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none">
|
||
${targetOptions}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||
<i class="fas fa-cog mr-1"></i>Variables supplémentaires (JSON, optionnel)
|
||
</label>
|
||
<textarea id="run-playbook-vars" rows="3"
|
||
class="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg focus:border-purple-500 focus:outline-none font-mono text-sm"
|
||
placeholder='{"key": "value"}'></textarea>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-4">
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="run-playbook-check" class="rounded bg-gray-700 border-gray-600 text-purple-500 focus:ring-purple-500">
|
||
<span class="text-sm text-gray-300">Mode simulation (--check)</span>
|
||
</label>
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="run-playbook-verbose" class="rounded bg-gray-700 border-gray-600 text-purple-500 focus:ring-purple-500">
|
||
<span class="text-sm text-gray-300">Verbose (-v)</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="flex space-x-3 pt-4 border-t border-gray-700">
|
||
<button onclick="dashboard.executePlaybookFromModal('${this.escapeHtml(filename)}')" class="flex-1 btn-primary bg-green-600 hover:bg-green-500 py-3 rounded-lg font-medium">
|
||
<i class="fas fa-play mr-2"></i>Exécuter
|
||
</button>
|
||
<button onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async executePlaybookFromModal(filename) {
|
||
const target = document.getElementById('run-playbook-target')?.value || 'all';
|
||
const varsText = document.getElementById('run-playbook-vars')?.value || '';
|
||
const checkMode = document.getElementById('run-playbook-check')?.checked || false;
|
||
const verbose = document.getElementById('run-playbook-verbose')?.checked || false;
|
||
|
||
let extraVars = {};
|
||
if (varsText.trim()) {
|
||
try {
|
||
extraVars = JSON.parse(varsText);
|
||
} catch (e) {
|
||
this.showNotification('Variables JSON invalides', 'error');
|
||
return;
|
||
}
|
||
}
|
||
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
const result = await this.apiCall('/api/ansible/execute', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
playbook: filename,
|
||
target: target,
|
||
extra_vars: extraVars,
|
||
check_mode: checkMode,
|
||
verbose: verbose
|
||
})
|
||
});
|
||
|
||
this.hideLoading();
|
||
|
||
// Afficher le résultat
|
||
const statusColor = result.success ? 'bg-green-900/30 border-green-600' : 'bg-red-900/30 border-red-600';
|
||
const statusIcon = result.success ? 'fa-check-circle text-green-500' : 'fa-times-circle text-red-500';
|
||
|
||
this.showModal(`Résultat: ${filename}`, `
|
||
<div class="space-y-4">
|
||
<div class="p-4 ${statusColor} border rounded-lg">
|
||
<div class="flex items-center space-x-3">
|
||
<i class="fas ${statusIcon} text-2xl"></i>
|
||
<div>
|
||
<p class="font-semibold">${result.success ? 'Exécution réussie' : 'Échec de l\'exécution'}</p>
|
||
<p class="text-sm text-gray-400">Cible: ${target} • Durée: ${result.execution_time || '?'}s</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="p-4 bg-gray-800 rounded-lg max-h-64 overflow-auto">
|
||
<pre class="text-xs text-gray-300 whitespace-pre-wrap font-mono">${this.escapeHtml(result.stdout || '(pas de sortie)')}</pre>
|
||
</div>
|
||
${result.stderr ? `
|
||
<div class="p-4 bg-red-900/20 rounded-lg max-h-32 overflow-auto">
|
||
<h4 class="text-sm font-semibold text-red-400 mb-2">Erreurs:</h4>
|
||
<pre class="text-xs text-red-300 whitespace-pre-wrap font-mono">${this.escapeHtml(result.stderr)}</pre>
|
||
</div>
|
||
` : ''}
|
||
<button onclick="dashboard.closeModal()" class="w-full btn-primary">Fermer</button>
|
||
</div>
|
||
`);
|
||
|
||
this.showNotification(
|
||
result.success ? `Playbook exécuté avec succès` : `Échec du playbook`,
|
||
result.success ? 'success' : 'error'
|
||
);
|
||
|
||
// Rafraîchir les tâches
|
||
await this.loadTaskLogsWithFilters();
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
confirmDeletePlaybook(filename) {
|
||
this.showModal('Confirmer la suppression', `
|
||
<div class="space-y-4">
|
||
<div class="p-4 bg-red-900/30 border border-red-600 rounded-lg">
|
||
<div class="flex items-start space-x-3">
|
||
<i class="fas fa-exclamation-triangle text-red-500 text-2xl mt-1"></i>
|
||
<div>
|
||
<h4 class="font-semibold text-red-400">Attention !</h4>
|
||
<p class="text-sm text-gray-300 mt-1">
|
||
Vous êtes sur le point de supprimer le playbook <strong class="text-white">${this.escapeHtml(filename)}</strong>.
|
||
</p>
|
||
<p class="text-sm text-gray-400 mt-2">
|
||
Cette action est irréversible.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex space-x-3">
|
||
<button onclick="dashboard.deletePlaybook('${this.escapeHtml(filename)}')" class="flex-1 px-4 py-3 bg-red-600 rounded-lg hover:bg-red-500 transition-colors font-medium">
|
||
<i class="fas fa-trash mr-2"></i>Supprimer définitivement
|
||
</button>
|
||
<button onclick="dashboard.closeModal()" class="px-6 py-3 border border-gray-600 rounded-lg text-gray-300 hover:border-gray-500 transition-colors">
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async deletePlaybook(filename) {
|
||
this.closeModal();
|
||
this.showLoading();
|
||
|
||
try {
|
||
await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
this.hideLoading();
|
||
this.showNotification(`Playbook "${filename}" supprimé`, 'success');
|
||
|
||
// Rafraîchir la liste
|
||
await this.refreshPlaybooks();
|
||
|
||
} catch (error) {
|
||
this.hideLoading();
|
||
this.showNotification(`Erreur suppression: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
showModal(title, content, options = {}) {
|
||
const modalCard = document.querySelector('#modal .glass-card');
|
||
document.getElementById('modal-title').textContent = title;
|
||
document.getElementById('modal-content').innerHTML = content;
|
||
document.getElementById('modal').classList.remove('hidden');
|
||
|
||
// Appliquer classe spéciale pour Ad-Hoc console
|
||
if (title.includes('Ad-Hoc')) {
|
||
modalCard.classList.add('adhoc-modal');
|
||
} else {
|
||
modalCard.classList.remove('adhoc-modal');
|
||
}
|
||
|
||
// Animate modal appearance
|
||
anime({
|
||
targets: '#modal .glass-card',
|
||
scale: [0.8, 1],
|
||
opacity: [0, 1],
|
||
duration: 300,
|
||
easing: 'easeOutExpo'
|
||
});
|
||
}
|
||
|
||
// ===== MÉTHODES DU PLANIFICATEUR (SCHEDULES) =====
|
||
|
||
renderSchedules() {
|
||
const listContainer = document.getElementById('schedules-list');
|
||
const emptyState = document.getElementById('schedules-empty');
|
||
|
||
if (!listContainer) return;
|
||
|
||
// Filtrer les schedules
|
||
let filteredSchedules = [...this.schedules];
|
||
|
||
if (this.currentScheduleFilter === 'active') {
|
||
filteredSchedules = filteredSchedules.filter(s => s.enabled);
|
||
} else if (this.currentScheduleFilter === 'paused') {
|
||
filteredSchedules = filteredSchedules.filter(s => !s.enabled);
|
||
}
|
||
|
||
if (this.scheduleSearchQuery) {
|
||
const query = this.scheduleSearchQuery.toLowerCase();
|
||
filteredSchedules = filteredSchedules.filter(s =>
|
||
s.name.toLowerCase().includes(query) ||
|
||
s.playbook.toLowerCase().includes(query) ||
|
||
s.target.toLowerCase().includes(query)
|
||
);
|
||
}
|
||
|
||
// Mettre à jour les stats
|
||
this.updateSchedulesStats();
|
||
|
||
// Afficher l'état vide ou la liste
|
||
if (this.schedules.length === 0) {
|
||
listContainer.innerHTML = '';
|
||
emptyState?.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
emptyState?.classList.add('hidden');
|
||
|
||
if (filteredSchedules.length === 0) {
|
||
listContainer.innerHTML = `
|
||
<div class="text-center py-8 text-gray-400">
|
||
<i class="fas fa-search text-2xl mb-2"></i>
|
||
<p>Aucun schedule trouvé pour ces critères</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
listContainer.innerHTML = filteredSchedules.map(schedule => this.renderScheduleCard(schedule)).join('');
|
||
|
||
// Mettre à jour les prochaines exécutions
|
||
this.renderUpcomingExecutions();
|
||
}
|
||
|
||
renderScheduleCard(schedule) {
|
||
const statusClass = schedule.enabled ? 'active' : 'paused';
|
||
const statusChipClass = schedule.enabled ? 'active' : 'paused';
|
||
const statusText = schedule.enabled ? 'Actif' : 'En pause';
|
||
|
||
// Formater la prochaine exécution
|
||
let nextRunText = '--';
|
||
if (schedule.next_run_at) {
|
||
const nextRun = new Date(schedule.next_run_at);
|
||
nextRunText = this.formatRelativeTime(nextRun);
|
||
}
|
||
|
||
// Formater la dernière exécution
|
||
let lastRunHtml = '';
|
||
if (schedule.last_run_at) {
|
||
const lastStatusIcon = schedule.last_status === 'success' ? '✅' :
|
||
schedule.last_status === 'failed' ? '❌' :
|
||
schedule.last_status === 'running' ? '🔄' : '';
|
||
const lastRunDate = new Date(schedule.last_run_at);
|
||
lastRunHtml = `<span class="text-gray-500">| Dernier: ${lastStatusIcon} ${this.formatRelativeTime(lastRunDate)}</span>`;
|
||
}
|
||
|
||
// Formater la récurrence
|
||
let recurrenceText = 'Exécution unique';
|
||
if (schedule.schedule_type === 'recurring' && schedule.recurrence) {
|
||
const rec = schedule.recurrence;
|
||
if (rec.type === 'daily') {
|
||
recurrenceText = `Tous les jours à ${rec.time}`;
|
||
} else if (rec.type === 'weekly') {
|
||
const days = (rec.days || []).map(d => ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'][d-1]).join(', ');
|
||
recurrenceText = `Chaque ${days} à ${rec.time}`;
|
||
} else if (rec.type === 'monthly') {
|
||
recurrenceText = `Le ${rec.day_of_month || 1} de chaque mois à ${rec.time}`;
|
||
} else if (rec.type === 'custom') {
|
||
recurrenceText = `Cron: ${rec.cron_expression}`;
|
||
}
|
||
}
|
||
|
||
// Tags
|
||
const tagsHtml = (schedule.tags || []).map(tag =>
|
||
`<span class="schedule-tag">${tag}</span>`
|
||
).join('');
|
||
|
||
return `
|
||
<div class="schedule-card ${statusClass}" data-schedule-id="${schedule.id}">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-3 mb-2">
|
||
<span class="font-semibold text-lg">${schedule.name}</span>
|
||
<span class="schedule-status-chip ${statusChipClass}">${statusText}</span>
|
||
${tagsHtml}
|
||
</div>
|
||
<div class="text-sm text-gray-400 mb-2">
|
||
${schedule.description || ''}
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||
<span class="px-2 py-1 bg-blue-600/20 text-blue-400 rounded">${schedule.playbook}</span>
|
||
<span class="px-2 py-1 bg-purple-600/20 text-purple-400 rounded">
|
||
<i class="fas fa-${schedule.target_type === 'group' ? 'layer-group' : 'server'} mr-1"></i>
|
||
${schedule.target}
|
||
</span>
|
||
<span class="text-gray-500">${recurrenceText}</span>
|
||
</div>
|
||
<div class="mt-2 text-xs text-gray-400">
|
||
<span>Prochaine: <span class="text-blue-400">${nextRunText}</span></span>
|
||
${lastRunHtml}
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2 ml-4">
|
||
<button onclick="dashboard.runScheduleNow('${schedule.id}')" class="schedule-action-btn run" title="Exécuter maintenant">
|
||
<i class="fas fa-play"></i>
|
||
</button>
|
||
${schedule.enabled ? `
|
||
<button onclick="dashboard.pauseSchedule('${schedule.id}')" class="schedule-action-btn pause" title="Mettre en pause">
|
||
<i class="fas fa-pause"></i>
|
||
</button>
|
||
` : `
|
||
<button onclick="dashboard.resumeSchedule('${schedule.id}')" class="schedule-action-btn run" title="Reprendre">
|
||
<i class="fas fa-play"></i>
|
||
</button>
|
||
`}
|
||
<button onclick="dashboard.showEditScheduleModal('${schedule.id}')" class="schedule-action-btn edit" title="Modifier">
|
||
<i class="fas fa-edit"></i>
|
||
</button>
|
||
<button onclick="dashboard.showScheduleHistory('${schedule.id}')" class="schedule-action-btn edit" title="Historique">
|
||
<i class="fas fa-history"></i>
|
||
</button>
|
||
<button onclick="dashboard.deleteSchedule('${schedule.id}')" class="schedule-action-btn delete" title="Supprimer">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
updateSchedulesStats() {
|
||
const activeCount = this.schedules.filter(s => s.enabled).length;
|
||
const pausedCount = this.schedules.filter(s => !s.enabled).length;
|
||
|
||
const activeCountEl = document.getElementById('schedules-active-count');
|
||
if (activeCountEl) activeCountEl.textContent = activeCount;
|
||
|
||
const pausedCountEl = document.getElementById('schedules-paused-count');
|
||
if (pausedCountEl) pausedCountEl.textContent = pausedCount;
|
||
|
||
const failuresEl = document.getElementById('schedules-failures-24h');
|
||
if (failuresEl) failuresEl.textContent = this.schedulesStats.failures_24h || 0;
|
||
|
||
// Dashboard widget
|
||
const dashboardActiveEl = document.getElementById('dashboard-schedules-active');
|
||
if (dashboardActiveEl) dashboardActiveEl.textContent = activeCount;
|
||
|
||
const dashboardFailuresEl = document.getElementById('dashboard-schedules-failures');
|
||
if (dashboardFailuresEl) dashboardFailuresEl.textContent = this.schedulesStats.failures_24h || 0;
|
||
|
||
// Prochaine exécution
|
||
const activeSchedules = this.schedules.filter(s => s.enabled && s.next_run_at);
|
||
if (activeSchedules.length > 0) {
|
||
activeSchedules.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at));
|
||
const nextRun = new Date(activeSchedules[0].next_run_at);
|
||
const now = new Date();
|
||
const diffMs = nextRun - now;
|
||
|
||
const nextRunEl = document.getElementById('schedules-next-run');
|
||
const dashboardNextEl = document.getElementById('dashboard-schedules-next');
|
||
|
||
if (diffMs > 0) {
|
||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||
const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||
let nextText;
|
||
if (hours > 0) {
|
||
nextText = `${hours}h${mins}m`;
|
||
} else {
|
||
nextText = `${mins}min`;
|
||
}
|
||
if (nextRunEl) nextRunEl.textContent = nextText;
|
||
if (dashboardNextEl) dashboardNextEl.textContent = nextText;
|
||
} else {
|
||
if (nextRunEl) nextRunEl.textContent = 'Imminente';
|
||
if (dashboardNextEl) dashboardNextEl.textContent = 'Imminent';
|
||
}
|
||
} else {
|
||
const nextRunEl = document.getElementById('schedules-next-run');
|
||
const dashboardNextEl = document.getElementById('dashboard-schedules-next');
|
||
if (nextRunEl) nextRunEl.textContent = '--:--';
|
||
if (dashboardNextEl) dashboardNextEl.textContent = '--';
|
||
}
|
||
|
||
// Update dashboard upcoming schedules
|
||
this.updateDashboardUpcomingSchedules();
|
||
}
|
||
|
||
updateDashboardUpcomingSchedules() {
|
||
const container = document.getElementById('dashboard-upcoming-schedules');
|
||
if (!container) return;
|
||
|
||
const upcoming = this.schedules
|
||
.filter(s => s.enabled && s.next_run_at)
|
||
.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at))
|
||
.slice(0, 3);
|
||
|
||
if (upcoming.length === 0) {
|
||
container.innerHTML = '<p class="text-gray-500 text-center py-2">Aucun schedule actif</p>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = upcoming.map(s => {
|
||
const nextRun = new Date(s.next_run_at);
|
||
return `
|
||
<div class="flex items-center justify-between p-2 bg-gray-800/30 rounded">
|
||
<div class="flex items-center gap-2 min-w-0">
|
||
<div class="w-1.5 h-1.5 bg-green-400 rounded-full flex-shrink-0"></div>
|
||
<span class="truncate">${s.name}</span>
|
||
</div>
|
||
<span class="text-blue-400 text-xs flex-shrink-0 ml-2">${nextRun.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
renderUpcomingExecutions() {
|
||
const container = document.getElementById('schedules-upcoming');
|
||
if (!container) return;
|
||
|
||
const activeSchedules = this.schedules
|
||
.filter(s => s.enabled && s.next_run_at)
|
||
.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at))
|
||
.slice(0, 5);
|
||
|
||
if (activeSchedules.length === 0) {
|
||
container.innerHTML = '<p class="text-gray-500 text-sm">Aucune exécution planifiée</p>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = activeSchedules.map(s => {
|
||
const nextRun = new Date(s.next_run_at);
|
||
return `
|
||
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-2 h-2 bg-green-400 rounded-full"></div>
|
||
<div>
|
||
<div class="font-medium text-sm">${s.name}</div>
|
||
<div class="text-xs text-gray-500">${s.playbook} → ${s.target}</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<div class="text-sm text-blue-400">${nextRun.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</div>
|
||
<div class="text-xs text-gray-500">${nextRun.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
formatRelativeTime(date) {
|
||
const now = new Date();
|
||
const diffMs = date - now;
|
||
const absDiffMs = Math.abs(diffMs);
|
||
const isPast = diffMs < 0;
|
||
|
||
const mins = Math.floor(absDiffMs / (1000 * 60));
|
||
const hours = Math.floor(absDiffMs / (1000 * 60 * 60));
|
||
const days = Math.floor(absDiffMs / (1000 * 60 * 60 * 24));
|
||
|
||
if (days > 0) {
|
||
return isPast ? `il y a ${days}j` : `dans ${days}j`;
|
||
} else if (hours > 0) {
|
||
return isPast ? `il y a ${hours}h` : `dans ${hours}h`;
|
||
} else if (mins > 0) {
|
||
return isPast ? `il y a ${mins}min` : `dans ${mins}min`;
|
||
} else {
|
||
return isPast ? 'à l\'instant' : 'imminent';
|
||
}
|
||
}
|
||
|
||
filterSchedules(filter) {
|
||
this.currentScheduleFilter = filter;
|
||
|
||
// Mettre à jour les boutons
|
||
document.querySelectorAll('.schedule-filter-btn').forEach(btn => {
|
||
btn.classList.remove('active', 'bg-purple-600', 'text-white');
|
||
btn.classList.add('bg-gray-700', 'text-gray-300');
|
||
if (btn.dataset.filter === filter) {
|
||
btn.classList.add('active', 'bg-purple-600', 'text-white');
|
||
btn.classList.remove('bg-gray-700', 'text-gray-300');
|
||
}
|
||
});
|
||
|
||
this.renderSchedules();
|
||
}
|
||
|
||
searchSchedules(query) {
|
||
this.scheduleSearchQuery = query;
|
||
this.renderSchedules();
|
||
}
|
||
|
||
toggleScheduleView(view) {
|
||
const listView = document.getElementById('schedules-list-view');
|
||
const calendarView = document.getElementById('schedules-calendar-view');
|
||
|
||
if (view === 'calendar') {
|
||
listView?.classList.add('hidden');
|
||
calendarView?.classList.remove('hidden');
|
||
this.renderScheduleCalendar();
|
||
} else {
|
||
listView?.classList.remove('hidden');
|
||
calendarView?.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
async refreshSchedules() {
|
||
try {
|
||
const [schedulesData, statsData] = await Promise.all([
|
||
this.apiCall('/api/schedules'),
|
||
this.apiCall('/api/schedules/stats')
|
||
]);
|
||
|
||
this.schedules = schedulesData.schedules || [];
|
||
this.schedulesStats = statsData.stats || {};
|
||
this.schedulesUpcoming = statsData.upcoming || [];
|
||
|
||
this.renderSchedules();
|
||
this.showNotification('Schedules rafraîchis', 'success');
|
||
} catch (error) {
|
||
this.showNotification('Erreur lors du rafraîchissement', 'error');
|
||
}
|
||
}
|
||
|
||
async refreshSchedulesStats() {
|
||
try {
|
||
const statsData = await this.apiCall('/api/schedules/stats');
|
||
this.schedulesStats = statsData.stats || {};
|
||
this.schedulesUpcoming = statsData.upcoming || [];
|
||
this.updateSchedulesStats();
|
||
} catch (error) {
|
||
console.error('Erreur rafraîchissement stats schedules:', error);
|
||
}
|
||
}
|
||
|
||
// ===== ACTIONS SCHEDULES =====
|
||
|
||
async runScheduleNow(scheduleId) {
|
||
if (!confirm('Exécuter ce schedule immédiatement ?')) return;
|
||
|
||
try {
|
||
this.showLoading();
|
||
await this.apiCall(`/api/schedules/${scheduleId}/run`, { method: 'POST' });
|
||
this.showNotification('Schedule lancé', 'success');
|
||
} catch (error) {
|
||
this.showNotification('Erreur lors du lancement', 'error');
|
||
} finally {
|
||
this.hideLoading();
|
||
}
|
||
}
|
||
|
||
async pauseSchedule(scheduleId) {
|
||
try {
|
||
const result = await this.apiCall(`/api/schedules/${scheduleId}/pause`, { method: 'POST' });
|
||
const schedule = this.schedules.find(s => s.id === scheduleId);
|
||
if (schedule) schedule.enabled = false;
|
||
this.renderSchedules();
|
||
this.showNotification(`Schedule mis en pause`, 'success');
|
||
} catch (error) {
|
||
this.showNotification('Erreur lors de la mise en pause', 'error');
|
||
}
|
||
}
|
||
|
||
async resumeSchedule(scheduleId) {
|
||
try {
|
||
const result = await this.apiCall(`/api/schedules/${scheduleId}/resume`, { method: 'POST' });
|
||
const schedule = this.schedules.find(s => s.id === scheduleId);
|
||
if (schedule) schedule.enabled = true;
|
||
this.renderSchedules();
|
||
this.showNotification(`Schedule repris`, 'success');
|
||
} catch (error) {
|
||
this.showNotification('Erreur lors de la reprise', 'error');
|
||
}
|
||
}
|
||
|
||
async deleteSchedule(scheduleId) {
|
||
const schedule = this.schedules.find(s => s.id === scheduleId);
|
||
if (!schedule) return;
|
||
|
||
if (!confirm(`Supprimer le schedule "${schedule.name}" ?`)) return;
|
||
|
||
try {
|
||
await this.apiCall(`/api/schedules/${scheduleId}`, { method: 'DELETE' });
|
||
this.schedules = this.schedules.filter(s => s.id !== scheduleId);
|
||
this.renderSchedules();
|
||
this.showNotification(`Schedule supprimé`, 'success');
|
||
} catch (error) {
|
||
this.showNotification('Erreur lors de la suppression', 'error');
|
||
}
|
||
}
|
||
|
||
// ===== MODAL CRÉATION/ÉDITION SCHEDULE =====
|
||
|
||
async showCreateScheduleModal(prefilledPlaybook = null) {
|
||
this.editingScheduleId = null;
|
||
this.scheduleModalStep = 1;
|
||
|
||
// S'assurer que les playbooks sont chargés
|
||
if (!this.playbooks || this.playbooks.length === 0) {
|
||
try {
|
||
const playbooksData = await this.apiCall('/api/ansible/playbooks');
|
||
this.playbooks = playbooksData.playbooks || [];
|
||
} catch (error) {
|
||
console.error('Erreur chargement playbooks:', error);
|
||
}
|
||
}
|
||
|
||
const content = this.getScheduleModalContent(null, prefilledPlaybook);
|
||
this.showModal('Nouveau Schedule', content, 'schedule-modal');
|
||
}
|
||
|
||
async showEditScheduleModal(scheduleId) {
|
||
const schedule = this.schedules.find(s => s.id === scheduleId);
|
||
if (!schedule) return;
|
||
|
||
this.editingScheduleId = scheduleId;
|
||
this.scheduleModalStep = 1;
|
||
|
||
// S'assurer que les playbooks sont chargés
|
||
if (!this.playbooks || this.playbooks.length === 0) {
|
||
try {
|
||
const playbooksData = await this.apiCall('/api/ansible/playbooks');
|
||
this.playbooks = playbooksData.playbooks || [];
|
||
} catch (error) {
|
||
console.error('Erreur chargement playbooks:', error);
|
||
}
|
||
}
|
||
|
||
const content = this.getScheduleModalContent(schedule);
|
||
this.showModal(`Modifier: ${schedule.name}`, content, 'schedule-modal');
|
||
}
|
||
|
||
getScheduleModalContent(schedule = null, prefilledPlaybook = null) {
|
||
const isEdit = !!schedule;
|
||
const s = schedule || {};
|
||
|
||
// Options de playbooks
|
||
const playbookOptions = this.playbooks.map(p =>
|
||
`<option value="${p.filename}" ${(s.playbook || prefilledPlaybook) === p.filename ? 'selected' : ''}>${p.name} (${p.filename})</option>`
|
||
).join('');
|
||
|
||
// Options de groupes
|
||
const groupOptions = this.ansibleGroups.map(g =>
|
||
`<option value="${g}" ${s.target === g ? 'selected' : ''}>${g}</option>`
|
||
).join('');
|
||
|
||
// Options d'hôtes
|
||
const hostOptions = this.ansibleHosts.map(h =>
|
||
`<option value="${h.name}" ${s.target === h.name ? 'selected' : ''}>${h.name}</option>`
|
||
).join('');
|
||
|
||
// Récurrence
|
||
const rec = s.recurrence || {};
|
||
const daysChecked = (rec.days || [1]);
|
||
|
||
return `
|
||
<div class="schedule-step-indicator">
|
||
<div class="schedule-step-dot active" data-step="1">1</div>
|
||
<div class="schedule-step-connector"></div>
|
||
<div class="schedule-step-dot" data-step="2">2</div>
|
||
<div class="schedule-step-connector"></div>
|
||
<div class="schedule-step-dot" data-step="3">3</div>
|
||
<div class="schedule-step-connector"></div>
|
||
<div class="schedule-step-dot" data-step="4">4</div>
|
||
</div>
|
||
|
||
<!-- Step 1: Informations de base -->
|
||
<div class="schedule-modal-step active" data-step="1">
|
||
<h4 class="text-lg font-semibold mb-4"><i class="fas fa-info-circle text-purple-400 mr-2"></i>Informations de base</h4>
|
||
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm text-gray-400 mb-1">Nom du schedule *</label>
|
||
<input type="text" id="schedule-name" value="${s.name || ''}"
|
||
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
|
||
placeholder="Ex: Backup quotidien production">
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm text-gray-400 mb-1">Description</label>
|
||
<textarea id="schedule-description" rows="2"
|
||
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
|
||
placeholder="Description optionnelle">${s.description || ''}</textarea>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm text-gray-400 mb-2">Tags</label>
|
||
<div class="flex flex-wrap gap-2">
|
||
${['Backup', 'Maintenance', 'Monitoring', 'Production', 'Test'].map(tag => `
|
||
<label class="flex items-center gap-2 px-3 py-1.5 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700">
|
||
<input type="checkbox" value="${tag}" class="schedule-tag-checkbox"
|
||
${(s.tags || []).includes(tag) ? 'checked' : ''}>
|
||
<span class="text-sm">${tag}</span>
|
||
</label>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-end mt-6">
|
||
<button onclick="dashboard.scheduleModalNextStep()" class="btn-primary">
|
||
Suivant <i class="fas fa-arrow-right ml-2"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 2: Quoi exécuter -->
|
||
<div class="schedule-modal-step" data-step="2">
|
||
<h4 class="text-lg font-semibold mb-4"><i class="fas fa-play text-blue-400 mr-2"></i>Quoi exécuter ?</h4>
|
||
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm text-gray-400 mb-1">Playbook *</label>
|
||
<select id="schedule-playbook" class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
|
||
<option value="">-- Sélectionner un playbook --</option>
|
||
${playbookOptions}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm text-gray-400 mb-2">Type de cible</label>
|
||
<div class="flex gap-4">
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input type="radio" name="schedule-target-type" value="group"
|
||
${(s.target_type || 'group') === 'group' ? 'checked' : ''}
|
||
onchange="dashboard.toggleScheduleTargetType('group')">
|
||
<span>Groupe Ansible</span>
|
||
</label>
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input type="radio" name="schedule-target-type" value="host"
|
||
${s.target_type === 'host' ? 'checked' : ''}
|
||
onchange="dashboard.toggleScheduleTargetType('host')">
|
||
<span>Hôte spécifique</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="schedule-group-select" class="${s.target_type === 'host' ? 'hidden' : ''}">
|
||
<label class="block text-sm text-gray-400 mb-1">Groupe cible</label>
|
||
<select id="schedule-target-group" class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
|
||
<option value="all" ${(s.target || 'all') === 'all' ? 'selected' : ''}>all (tous les hôtes)</option>
|
||
${groupOptions}
|
||
</select>
|
||
</div>
|
||
|
||
<div id="schedule-host-select" class="${s.target_type !== 'host' ? 'hidden' : ''}">
|
||
<label class="block text-sm text-gray-400 mb-1">Hôte cible</label>
|
||
<select id="schedule-target-host" class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
|
||
${hostOptions}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm text-gray-400 mb-1">Timeout (secondes)</label>
|
||
<input type="number" id="schedule-timeout" value="${s.timeout || 3600}" min="60" max="86400"
|
||
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-between mt-6">
|
||
<button onclick="dashboard.scheduleModalPrevStep()" class="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600">
|
||
<i class="fas fa-arrow-left mr-2"></i>Précédent
|
||
</button>
|
||
<button onclick="dashboard.scheduleModalNextStep()" class="btn-primary">
|
||
Suivant <i class="fas fa-arrow-right ml-2"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 3: Quand -->
|
||
<div class="schedule-modal-step" data-step="3">
|
||
<h4 class="text-lg font-semibold mb-4"><i class="fas fa-clock text-green-400 mr-2"></i>Quand exécuter ?</h4>
|
||
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm text-gray-400 mb-2">Type de planification</label>
|
||
<div class="flex gap-4">
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input type="radio" name="schedule-type" value="recurring"
|
||
${(s.schedule_type || 'recurring') === 'recurring' ? 'checked' : ''}
|
||
onchange="dashboard.toggleScheduleType('recurring')">
|
||
<span>Récurrent</span>
|
||
</label>
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input type="radio" name="schedule-type" value="once"
|
||
${s.schedule_type === 'once' ? 'checked' : ''}
|
||
onchange="dashboard.toggleScheduleType('once')">
|
||
<span>Exécution unique</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Options récurrence -->
|
||
<div id="schedule-recurring-options" class="${s.schedule_type === 'once' ? 'hidden' : ''}">
|
||
<div class="mb-4">
|
||
<label class="block text-sm text-gray-400 mb-1">Fréquence</label>
|
||
<select id="schedule-recurrence-type"
|
||
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg"
|
||
onchange="dashboard.updateRecurrenceOptions()">
|
||
<option value="daily" ${(rec.type || 'daily') === 'daily' ? 'selected' : ''}>Tous les jours</option>
|
||
<option value="weekly" ${rec.type === 'weekly' ? 'selected' : ''}>Toutes les semaines</option>
|
||
<option value="monthly" ${rec.type === 'monthly' ? 'selected' : ''}>Tous les mois</option>
|
||
<option value="custom" ${rec.type === 'custom' ? 'selected' : ''}>Expression cron personnalisée</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="recurrence-time" class="${rec.type === 'custom' ? 'hidden' : ''}">
|
||
<label class="block text-sm text-gray-400 mb-1">Heure d'exécution</label>
|
||
<input type="time" id="schedule-time" value="${rec.time || '02:00'}"
|
||
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
|
||
</div>
|
||
|
||
<div id="recurrence-weekly-days" class="${rec.type !== 'weekly' ? 'hidden' : ''} mt-4">
|
||
<label class="block text-sm text-gray-400 mb-2">Jours de la semaine</label>
|
||
<div class="flex flex-wrap gap-2">
|
||
${['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day, i) => `
|
||
<label class="flex items-center gap-1 px-3 py-1.5 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700">
|
||
<input type="checkbox" value="${i+1}" class="schedule-day-checkbox"
|
||
${daysChecked.includes(i+1) ? 'checked' : ''}>
|
||
<span class="text-sm">${day}</span>
|
||
</label>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<div id="recurrence-monthly-day" class="${rec.type !== 'monthly' ? 'hidden' : ''} mt-4">
|
||
<label class="block text-sm text-gray-400 mb-1">Jour du mois</label>
|
||
<input type="number" id="schedule-day-of-month" value="${rec.day_of_month || 1}" min="1" max="31"
|
||
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
|
||
</div>
|
||
|
||
<div id="recurrence-cron" class="${rec.type !== 'custom' ? 'hidden' : ''} mt-4">
|
||
<label class="block text-sm text-gray-400 mb-1">Expression cron</label>
|
||
<input type="text" id="schedule-cron" value="${rec.cron_expression || '0 2 * * *'}"
|
||
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg font-mono"
|
||
placeholder="minute heure jour mois jour_semaine"
|
||
oninput="dashboard.validateCronExpression(this.value)">
|
||
<div class="text-xs text-gray-500 mt-1">
|
||
<a href="https://crontab.guru/" target="_blank" class="text-purple-400 hover:underline">
|
||
<i class="fas fa-external-link-alt mr-1"></i>Aide crontab.guru
|
||
</a>
|
||
</div>
|
||
<div id="cron-validation" class="mt-2 text-sm"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Options exécution unique -->
|
||
<div id="schedule-once-options" class="${s.schedule_type !== 'once' ? 'hidden' : ''}">
|
||
<label class="block text-sm text-gray-400 mb-1">Date et heure d'exécution</label>
|
||
<input type="datetime-local" id="schedule-start-at"
|
||
value="${s.start_at ? s.start_at.slice(0, 16) : ''}"
|
||
class="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg">
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<label class="flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" id="schedule-enabled" ${(s.enabled !== false) ? 'checked' : ''}>
|
||
<span>Activer immédiatement</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-between mt-6">
|
||
<button onclick="dashboard.scheduleModalPrevStep()" class="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600">
|
||
<i class="fas fa-arrow-left mr-2"></i>Précédent
|
||
</button>
|
||
<button onclick="dashboard.scheduleModalNextStep()" class="btn-primary">
|
||
Suivant <i class="fas fa-arrow-right ml-2"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 4: Notifications -->
|
||
<div class="schedule-modal-step" data-step="4">
|
||
<h4 class="text-lg font-semibold mb-4"><i class="fas fa-bell text-yellow-400 mr-2"></i>Notifications</h4>
|
||
|
||
<div id="ntfy-disabled-warning" class="hidden mb-4 p-3 bg-yellow-900/30 border border-yellow-600 rounded-lg">
|
||
<div class="flex items-center gap-2 text-yellow-400">
|
||
<i class="fas fa-exclamation-triangle"></i>
|
||
<span class="font-medium">Notifications désactivées</span>
|
||
</div>
|
||
<p class="text-sm text-yellow-300/80 mt-1">Les notifications ntfy sont actuellement désactivées dans la configuration du serveur (NTFY_ENABLED=false). Les paramètres ci-dessous seront ignorés.</p>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm text-gray-400 mb-3">Type de notification</label>
|
||
<div class="space-y-3">
|
||
<label class="flex items-start gap-3 p-3 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700 border border-transparent has-[:checked]:border-purple-500">
|
||
<input type="radio" name="schedule-notification-type" value="all"
|
||
${(s.notification_type || 'all') === 'all' ? 'checked' : ''}
|
||
class="mt-1">
|
||
<div>
|
||
<span class="font-medium"><i class="fas fa-bell text-green-400 mr-2"></i>Toujours notifier</span>
|
||
<p class="text-sm text-gray-400 mt-1">Recevoir une notification à chaque exécution (succès ou échec)</p>
|
||
</div>
|
||
</label>
|
||
<label class="flex items-start gap-3 p-3 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700 border border-transparent has-[:checked]:border-purple-500">
|
||
<input type="radio" name="schedule-notification-type" value="errors"
|
||
${s.notification_type === 'errors' ? 'checked' : ''}
|
||
class="mt-1">
|
||
<div>
|
||
<span class="font-medium"><i class="fas fa-exclamation-circle text-red-400 mr-2"></i>Erreurs seulement</span>
|
||
<p class="text-sm text-gray-400 mt-1">Recevoir une notification uniquement en cas d'échec</p>
|
||
</div>
|
||
</label>
|
||
<label class="flex items-start gap-3 p-3 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700 border border-transparent has-[:checked]:border-purple-500">
|
||
<input type="radio" name="schedule-notification-type" value="none"
|
||
${s.notification_type === 'none' ? 'checked' : ''}
|
||
class="mt-1">
|
||
<div>
|
||
<span class="font-medium"><i class="fas fa-bell-slash text-gray-400 mr-2"></i>Aucune notification</span>
|
||
<p class="text-sm text-gray-400 mt-1">Ne pas envoyer de notification pour ce schedule</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-between mt-6">
|
||
<button onclick="dashboard.scheduleModalPrevStep()" class="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600">
|
||
<i class="fas fa-arrow-left mr-2"></i>Précédent
|
||
</button>
|
||
<button onclick="dashboard.saveSchedule()" class="btn-primary">
|
||
<i class="fas fa-save mr-2"></i>${isEdit ? 'Enregistrer' : 'Créer le schedule'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async scheduleModalNextStep() {
|
||
if (this.scheduleModalStep < 4) {
|
||
this.scheduleModalStep++;
|
||
this.updateScheduleModalStep();
|
||
|
||
// Si on arrive à l'étape 4 (Notifications), vérifier si NTFY est activé
|
||
if (this.scheduleModalStep === 4) {
|
||
await this.checkNtfyStatus();
|
||
}
|
||
}
|
||
}
|
||
|
||
async checkNtfyStatus() {
|
||
try {
|
||
const config = await this.apiCall('/api/notifications/config');
|
||
const warningEl = document.getElementById('ntfy-disabled-warning');
|
||
if (warningEl) {
|
||
warningEl.classList.toggle('hidden', config.enabled !== false);
|
||
}
|
||
} catch (error) {
|
||
console.error('Erreur vérification statut NTFY:', error);
|
||
}
|
||
}
|
||
|
||
scheduleModalPrevStep() {
|
||
if (this.scheduleModalStep > 1) {
|
||
this.scheduleModalStep--;
|
||
this.updateScheduleModalStep();
|
||
}
|
||
}
|
||
|
||
updateScheduleModalStep() {
|
||
// Mettre à jour les indicateurs
|
||
document.querySelectorAll('.schedule-step-dot').forEach(dot => {
|
||
const step = parseInt(dot.dataset.step);
|
||
dot.classList.remove('active', 'completed');
|
||
if (step < this.scheduleModalStep) {
|
||
dot.classList.add('completed');
|
||
} else if (step === this.scheduleModalStep) {
|
||
dot.classList.add('active');
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll('.schedule-step-connector').forEach((conn, i) => {
|
||
conn.classList.toggle('active', i < this.scheduleModalStep - 1);
|
||
});
|
||
|
||
// Afficher l'étape actuelle
|
||
document.querySelectorAll('.schedule-modal-step').forEach(step => {
|
||
step.classList.remove('active');
|
||
if (parseInt(step.dataset.step) === this.scheduleModalStep) {
|
||
step.classList.add('active');
|
||
}
|
||
});
|
||
}
|
||
|
||
toggleScheduleTargetType(type) {
|
||
document.getElementById('schedule-group-select')?.classList.toggle('hidden', type === 'host');
|
||
document.getElementById('schedule-host-select')?.classList.toggle('hidden', type === 'group');
|
||
}
|
||
|
||
toggleScheduleType(type) {
|
||
document.getElementById('schedule-recurring-options')?.classList.toggle('hidden', type === 'once');
|
||
document.getElementById('schedule-once-options')?.classList.toggle('hidden', type === 'recurring');
|
||
}
|
||
|
||
updateRecurrenceOptions() {
|
||
const type = document.getElementById('schedule-recurrence-type')?.value;
|
||
|
||
document.getElementById('recurrence-time')?.classList.toggle('hidden', type === 'custom');
|
||
document.getElementById('recurrence-weekly-days')?.classList.toggle('hidden', type !== 'weekly');
|
||
document.getElementById('recurrence-monthly-day')?.classList.toggle('hidden', type !== 'monthly');
|
||
document.getElementById('recurrence-cron')?.classList.toggle('hidden', type !== 'custom');
|
||
}
|
||
|
||
async validateCronExpression(expression) {
|
||
const container = document.getElementById('cron-validation');
|
||
if (!container || !expression.trim()) {
|
||
if (container) container.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await this.apiCall(`/api/schedules/validate-cron?expression=${encodeURIComponent(expression)}`);
|
||
if (result.valid) {
|
||
container.innerHTML = `
|
||
<div class="text-green-400"><i class="fas fa-check mr-1"></i>Expression valide</div>
|
||
<div class="text-xs text-gray-500 mt-1">Prochaines: ${result.next_runs?.slice(0, 3).map(r => new Date(r).toLocaleString('fr-FR')).join(', ')}</div>
|
||
`;
|
||
} else {
|
||
container.innerHTML = `<div class="text-red-400"><i class="fas fa-times mr-1"></i>${result.error}</div>`;
|
||
}
|
||
} catch (error) {
|
||
container.innerHTML = `<div class="text-red-400"><i class="fas fa-times mr-1"></i>Erreur de validation</div>`;
|
||
}
|
||
}
|
||
|
||
async saveSchedule() {
|
||
const name = document.getElementById('schedule-name')?.value.trim();
|
||
const description = document.getElementById('schedule-description')?.value.trim();
|
||
const playbook = document.getElementById('schedule-playbook')?.value;
|
||
const targetType = document.querySelector('input[name="schedule-target-type"]:checked')?.value || 'group';
|
||
const targetGroup = document.getElementById('schedule-target-group')?.value;
|
||
const targetHost = document.getElementById('schedule-target-host')?.value;
|
||
const timeout = parseInt(document.getElementById('schedule-timeout')?.value) || 3600;
|
||
const scheduleType = document.querySelector('input[name="schedule-type"]:checked')?.value || 'recurring';
|
||
const enabled = document.getElementById('schedule-enabled')?.checked ?? true;
|
||
const notificationType = document.querySelector('input[name="schedule-notification-type"]:checked')?.value || 'all';
|
||
|
||
// Validation
|
||
if (!name || name.length < 3) {
|
||
this.showNotification('Le nom doit faire au moins 3 caractères', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!playbook) {
|
||
this.showNotification('Veuillez sélectionner un playbook', 'error');
|
||
return;
|
||
}
|
||
|
||
// Construire les tags
|
||
const tags = Array.from(document.querySelectorAll('.schedule-tag-checkbox:checked')).map(cb => cb.value);
|
||
|
||
// Construire la récurrence
|
||
let recurrence = null;
|
||
let startAt = null;
|
||
|
||
if (scheduleType === 'recurring') {
|
||
const recType = document.getElementById('schedule-recurrence-type')?.value || 'daily';
|
||
const time = document.getElementById('schedule-time')?.value || '02:00';
|
||
|
||
recurrence = { type: recType, time };
|
||
|
||
if (recType === 'weekly') {
|
||
recurrence.days = Array.from(document.querySelectorAll('.schedule-day-checkbox:checked')).map(cb => parseInt(cb.value));
|
||
if (recurrence.days.length === 0) recurrence.days = [1];
|
||
} else if (recType === 'monthly') {
|
||
recurrence.day_of_month = parseInt(document.getElementById('schedule-day-of-month')?.value) || 1;
|
||
} else if (recType === 'custom') {
|
||
recurrence.cron_expression = document.getElementById('schedule-cron')?.value;
|
||
if (!recurrence.cron_expression) {
|
||
this.showNotification('Veuillez entrer une expression cron', 'error');
|
||
return;
|
||
}
|
||
}
|
||
} else {
|
||
const startAtValue = document.getElementById('schedule-start-at')?.value;
|
||
if (startAtValue) {
|
||
startAt = new Date(startAtValue).toISOString();
|
||
}
|
||
}
|
||
|
||
const payload = {
|
||
name,
|
||
description: description || null,
|
||
playbook,
|
||
target_type: targetType,
|
||
target: targetType === 'host' ? targetHost : targetGroup,
|
||
timeout,
|
||
schedule_type: scheduleType,
|
||
recurrence,
|
||
start_at: startAt,
|
||
enabled,
|
||
tags,
|
||
notification_type: notificationType
|
||
};
|
||
|
||
try {
|
||
this.showLoading();
|
||
|
||
if (this.editingScheduleId) {
|
||
await this.apiCall(`/api/schedules/${this.editingScheduleId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||
} else {
|
||
await this.apiCall('/api/schedules', { method: 'POST', body: JSON.stringify(payload) });
|
||
}
|
||
|
||
this.closeModal();
|
||
await this.refreshSchedules();
|
||
} catch (error) {
|
||
this.showNotification(error.message || 'Erreur lors de la sauvegarde', 'error');
|
||
} finally {
|
||
this.hideLoading();
|
||
}
|
||
}
|
||
|
||
async showScheduleHistory(scheduleId) {
|
||
const schedule = this.schedules.find(s => s.id === scheduleId);
|
||
if (!schedule) return;
|
||
|
||
try {
|
||
const result = await this.apiCall(`/api/schedules/${scheduleId}/runs?limit=50`);
|
||
const runs = result.runs || [];
|
||
|
||
let content;
|
||
if (runs.length === 0) {
|
||
content = `
|
||
<div class="text-center py-8 text-gray-400">
|
||
<i class="fas fa-clock text-4xl mb-4"></i>
|
||
<p>Aucune exécution enregistrée</p>
|
||
<p class="text-sm">Le schedule n'a pas encore été exécuté.</p>
|
||
</div>
|
||
`;
|
||
} else {
|
||
content = `
|
||
<div class="mb-4 text-sm text-gray-400">
|
||
${runs.length} exécution(s) - Taux de succès: ${schedule.run_count > 0 ? Math.round((schedule.success_count / schedule.run_count) * 100) : 0}%
|
||
</div>
|
||
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||
${runs.map(run => {
|
||
const startedAt = new Date(run.started_at);
|
||
const statusClass = run.status === 'success' ? 'success' :
|
||
run.status === 'failed' ? 'failed' :
|
||
run.status === 'running' ? 'running' : 'scheduled';
|
||
const statusIcon = run.status === 'success' ? 'check-circle' :
|
||
run.status === 'failed' ? 'times-circle' :
|
||
run.status === 'running' ? 'spinner fa-spin' : 'clock';
|
||
|
||
return `
|
||
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||
<div class="flex items-center gap-3">
|
||
<span class="schedule-status-chip ${statusClass}">
|
||
<i class="fas fa-${statusIcon} mr-1"></i>${run.status}
|
||
</span>
|
||
<div>
|
||
<div class="text-sm">${startedAt.toLocaleString('fr-FR')}</div>
|
||
${run.duration_seconds ? `<div class="text-xs text-gray-500">Durée: ${run.duration_seconds.toFixed(1)}s</div>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="text-right text-sm">
|
||
${run.hosts_impacted > 0 ? `<span class="text-gray-400">${run.hosts_impacted} hôte(s)</span>` : ''}
|
||
${run.task_id ? `<a href="#" onclick="dashboard.showTaskDetail(${run.task_id})" class="text-purple-400 hover:underline ml-2">Voir tâche</a>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
this.showModal(`Historique: ${schedule.name}`, content);
|
||
} catch (error) {
|
||
this.showNotification('Erreur lors du chargement de l\'historique', 'error');
|
||
}
|
||
}
|
||
|
||
// ===== CALENDRIER DES SCHEDULES =====
|
||
|
||
renderScheduleCalendar() {
|
||
const grid = document.getElementById('schedule-calendar-grid');
|
||
const titleEl = document.getElementById('schedule-calendar-title');
|
||
if (!grid || !titleEl) return;
|
||
|
||
const year = this.scheduleCalendarMonth.getFullYear();
|
||
const month = this.scheduleCalendarMonth.getMonth();
|
||
|
||
// Titre
|
||
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
|
||
titleEl.textContent = `${monthNames[month]} ${year}`;
|
||
|
||
// Premier jour du mois
|
||
const firstDay = new Date(year, month, 1);
|
||
const lastDay = new Date(year, month + 1, 0);
|
||
|
||
// Ajuster pour commencer par Lundi (0 = Dimanche dans JS)
|
||
let startDay = firstDay.getDay() - 1;
|
||
if (startDay < 0) startDay = 6;
|
||
|
||
// Générer les jours
|
||
const days = [];
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
|
||
// Jours du mois précédent
|
||
const prevMonth = new Date(year, month, 0);
|
||
for (let i = startDay - 1; i >= 0; i--) {
|
||
days.push({
|
||
date: new Date(year, month - 1, prevMonth.getDate() - i),
|
||
otherMonth: true
|
||
});
|
||
}
|
||
|
||
// Jours du mois actuel
|
||
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||
const date = new Date(year, month, d);
|
||
days.push({
|
||
date,
|
||
otherMonth: false,
|
||
isToday: date.getTime() === today.getTime()
|
||
});
|
||
}
|
||
|
||
// Jours du mois suivant
|
||
const remainingDays = 42 - days.length;
|
||
for (let d = 1; d <= remainingDays; d++) {
|
||
days.push({
|
||
date: new Date(year, month + 1, d),
|
||
otherMonth: true
|
||
});
|
||
}
|
||
|
||
// Générer le HTML
|
||
grid.innerHTML = days.map(day => {
|
||
const dateStr = day.date.toISOString().split('T')[0];
|
||
const classes = ['schedule-calendar-day'];
|
||
if (day.otherMonth) classes.push('other-month');
|
||
if (day.isToday) classes.push('today');
|
||
|
||
// Événements pour ce jour (simplifiés - prochaines exécutions)
|
||
const events = this.schedulesUpcoming.filter(s => {
|
||
if (!s.next_run_at) return false;
|
||
const runDate = new Date(s.next_run_at).toISOString().split('T')[0];
|
||
return runDate === dateStr;
|
||
});
|
||
|
||
return `
|
||
<div class="${classes.join(' ')}" data-date="${dateStr}">
|
||
<div class="text-xs font-semibold mb-1 ${day.otherMonth ? 'text-gray-600' : 'text-gray-300'}">
|
||
${day.date.getDate()}
|
||
</div>
|
||
${events.slice(0, 2).map(e => `
|
||
<div class="schedule-calendar-event scheduled" title="${e.schedule_name}">
|
||
${e.schedule_name}
|
||
</div>
|
||
`).join('')}
|
||
${events.length > 2 ? `<div class="text-xs text-gray-500">+${events.length - 2}</div>` : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
prevCalendarMonth() {
|
||
this.scheduleCalendarMonth.setMonth(this.scheduleCalendarMonth.getMonth() - 1);
|
||
this.renderScheduleCalendar();
|
||
}
|
||
|
||
nextCalendarMonth() {
|
||
this.scheduleCalendarMonth.setMonth(this.scheduleCalendarMonth.getMonth() + 1);
|
||
this.renderScheduleCalendar();
|
||
}
|
||
|
||
// ===== FIN DES MÉTHODES PLANIFICATEUR =====
|
||
|
||
closeModal() {
|
||
const modal = document.getElementById('modal');
|
||
anime({
|
||
targets: '#modal .glass-card',
|
||
scale: [1, 0.8],
|
||
opacity: [1, 0],
|
||
duration: 200,
|
||
easing: 'easeInExpo',
|
||
complete: () => {
|
||
modal.classList.add('hidden');
|
||
}
|
||
});
|
||
}
|
||
|
||
showLoading() {
|
||
document.getElementById('loading-overlay').classList.remove('hidden');
|
||
}
|
||
|
||
hideLoading() {
|
||
document.getElementById('loading-overlay').classList.add('hidden');
|
||
}
|
||
|
||
showNotification(message, type = 'info') {
|
||
const notification = document.createElement('div');
|
||
notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${
|
||
type === 'success' ? 'bg-green-600' :
|
||
type === 'warning' ? 'bg-yellow-600' :
|
||
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
|
||
} text-white`;
|
||
notification.innerHTML = `
|
||
<div class="flex items-center space-x-3">
|
||
<i class="fas ${
|
||
type === 'success' ? 'fa-check-circle' :
|
||
type === 'warning' ? 'fa-exclamation-triangle' :
|
||
type === 'error' ? 'fa-exclamation-circle' : 'fa-info-circle'
|
||
}"></i>
|
||
<span>${message}</span>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(notification);
|
||
|
||
// Animate in
|
||
anime({
|
||
targets: notification,
|
||
translateX: [300, 0],
|
||
opacity: [0, 1],
|
||
duration: 300,
|
||
easing: 'easeOutExpo'
|
||
});
|
||
|
||
// Remove after 3 seconds
|
||
setTimeout(() => {
|
||
anime({
|
||
targets: notification,
|
||
translateX: [0, 300],
|
||
opacity: [1, 0],
|
||
duration: 300,
|
||
easing: 'easeInExpo',
|
||
complete: () => {
|
||
notification.remove();
|
||
}
|
||
});
|
||
}, 3000);
|
||
}
|
||
}
|
||
|
||
// Initialize dashboard when DOM is loaded
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
console.log('Creating DashboardManager...');
|
||
window.dashboard = new DashboardManager();
|
||
console.log('DashboardManager created. Methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(dashboard)).filter(m => m.includes('Schedule')));
|
||
});
|
||
|
||
// Global functions for onclick handlers
|
||
function showQuickActions() {
|
||
dashboard.showQuickActions();
|
||
}
|
||
|
||
function executeTask(taskType) {
|
||
dashboard.executeTask(taskType);
|
||
}
|
||
|
||
function addHost() {
|
||
dashboard.addHost();
|
||
}
|
||
|
||
function refreshTasks() {
|
||
dashboard.refreshTasks();
|
||
}
|
||
|
||
function clearLogs() {
|
||
dashboard.clearLogs();
|
||
}
|
||
|
||
function exportLogs() {
|
||
dashboard.exportLogs();
|
||
}
|
||
|
||
function closeModal() {
|
||
dashboard.closeModal();
|
||
}
|
||
|
||
window.showCreateScheduleModal = function(prefilledPlaybook = null) {
|
||
if (!window.dashboard) {
|
||
return;
|
||
}
|
||
|
||
if (typeof dashboard.showCreateScheduleModal === 'function') {
|
||
dashboard.showCreateScheduleModal(prefilledPlaybook);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
dashboard.editingScheduleId = null;
|
||
dashboard.scheduleModalStep = 1;
|
||
|
||
if (typeof dashboard.getScheduleModalContent === 'function' &&
|
||
typeof dashboard.showModal === 'function') {
|
||
const content = dashboard.getScheduleModalContent(null, prefilledPlaybook || null);
|
||
dashboard.showModal('Nouveau Schedule', content, 'schedule-modal');
|
||
}
|
||
} catch (e) {
|
||
console.error('showCreateScheduleModal fallback error:', e);
|
||
}
|
||
}; |