`);
}
async executePlaybookFromModal(filename) {
const target = document.getElementById('run-playbook-target')?.value || 'all';
const varsText = document.getElementById('run-playbook-vars')?.value || '';
const checkMode = document.getElementById('run-playbook-check')?.checked || false;
const verbose = document.getElementById('run-playbook-verbose')?.checked || false;
let extraVars = {};
if (varsText.trim()) {
try {
extraVars = JSON.parse(varsText);
} catch (e) {
this.showNotification('Variables JSON invalides', 'error');
return;
}
}
this.closeModal();
this.showLoading();
try {
const result = await this.apiCall('/api/ansible/execute', {
method: 'POST',
body: JSON.stringify({
playbook: filename,
target: target,
extra_vars: extraVars,
check_mode: checkMode,
verbose: verbose
})
});
this.hideLoading();
this.showNotification(
`Playbook exécuté sur ${target} (tâche ${result.task_id})`,
'success'
);
// Aller sur l'onglet Tâches et rafraîchir
this.setActiveNav('tasks');
await this.loadTaskLogsWithFilters();
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur: ${error.message}`, 'error');
}
}
confirmDeletePlaybook(filename) {
this.showModal('Confirmer la suppression', `
Attention !
Vous êtes sur le point de supprimer le playbook ${this.escapeHtml(filename)} .
Cette action est irréversible.
Supprimer définitivement
Annuler
`);
}
async deletePlaybook(filename) {
this.closeModal();
this.showLoading();
try {
await this.apiCall(`/api/playbooks/${encodeURIComponent(filename)}`, {
method: 'DELETE'
});
this.hideLoading();
this.showNotification(`Playbook "${filename}" supprimé`, 'success');
// Rafraîchir la liste
await this.refreshPlaybooks();
} catch (error) {
this.hideLoading();
this.showNotification(`Erreur suppression: ${error.message}`, 'error');
}
}
showModal(title, content, options = {}) {
const modalCard = document.querySelector('#modal .glass-card');
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-content').innerHTML = content;
document.getElementById('modal').classList.remove('hidden');
// Appliquer classe spéciale pour Ad-Hoc console
if (title.includes('Ad-Hoc')) {
modalCard.classList.add('adhoc-modal');
} else {
modalCard.classList.remove('adhoc-modal');
}
// Animate modal appearance
anime({
targets: '#modal .glass-card',
scale: [0.8, 1],
opacity: [0, 1],
duration: 300,
easing: 'easeOutExpo'
});
}
// ===== MÉTHODES DU WIDGET AD-HOC =====
adhocWidgetLimit = 5;
adhocWidgetOffset = 0;
/**
* Rendu du widget Console Ad-Hoc sur le dashboard
*/
renderAdhocWidget() {
const historyContainer = document.getElementById('adhoc-widget-history');
const loadMoreBtn = document.getElementById('adhoc-widget-load-more');
const successEl = document.getElementById('adhoc-widget-success');
const failedEl = document.getElementById('adhoc-widget-failed');
const totalEl = document.getElementById('adhoc-widget-total');
const countEl = document.getElementById('adhoc-widget-count');
if (!historyContainer) return;
// Calculer les stats
const total = this.adhocHistory.length;
const success = this.adhocHistory.filter(cmd => cmd.return_code === 0).length;
const failed = total - success;
// Mettre à jour les stats
if (successEl) successEl.textContent = success;
if (failedEl) failedEl.textContent = failed;
if (totalEl) totalEl.textContent = total;
if (countEl) countEl.textContent = total > 0 ? `${total} commande${total > 1 ? 's' : ''}` : '';
// Afficher les dernières exécutions
const displayedCommands = this.adhocHistory.slice(0, this.adhocWidgetLimit + this.adhocWidgetOffset);
if (displayedCommands.length === 0) {
historyContainer.innerHTML = `
Aucune exécution
Ouvrez la console pour exécuter des commandes
`;
if (loadMoreBtn) loadMoreBtn.classList.add('hidden');
return;
}
historyContainer.innerHTML = displayedCommands.map(cmd => {
const isSuccess = cmd.return_code === 0;
const statusColor = isSuccess ? 'text-green-400' : 'text-red-400';
const statusBg = isSuccess ? 'bg-green-900/30 border-green-700/50' : 'bg-red-900/30 border-red-700/50';
const statusIcon = isSuccess ? 'fa-check-circle' : 'fa-times-circle';
const statusText = isSuccess ? 'Succès' : 'Échec';
// Formater la date
const date = cmd.executed_at ? new Date(cmd.executed_at) : new Date();
const timeAgo = this.formatTimeAgo(date);
// Extraire le nom de la commande (première partie avant |)
const cmdName = cmd.command ? cmd.command.split('|')[0].trim().split(' ')[0] : 'Commande';
// Trouver la catégorie
const category = this.adhocCategories.find(c => c.name === cmd.category);
const catColor = category?.color || '#7c3aed';
return `
${this.escapeHtml(cmdName)}
${this.escapeHtml(cmd.category || 'default')}
${this.escapeHtml(cmd.target || 'all')}
${timeAgo}
${cmd.duration ? ` ${cmd.duration.toFixed(1)}s ` : ''}
`;
}).join('');
// Afficher/masquer le bouton "Charger plus"
if (loadMoreBtn) {
if (displayedCommands.length < total) {
loadMoreBtn.classList.remove('hidden');
loadMoreBtn.innerHTML = `
Charger plus (${total - displayedCommands.length} restantes)`;
} else {
loadMoreBtn.classList.add('hidden');
}
}
}
/**
* Charger plus d'historique dans le widget
*/
loadMoreAdhocHistory() {
this.adhocWidgetOffset += 5;
this.renderAdhocWidget();
}
/**
* Formater une date en "il y a X minutes/heures/jours"
*/
formatTimeAgo(date) {
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'À l\'instant';
if (diffMin < 60) return `Il y a ${diffMin}min`;
if (diffHour < 24) return `Il y a ${diffHour}h`;
if (diffDay < 7) return `Il y a ${diffDay}j`;
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' });
}
/**
* Rejouer une commande ad-hoc depuis l'historique
*/
replayAdhocCommand(commandId) {
const cmd = this.adhocHistory.find(c => c.id === commandId);
if (!cmd) {
this.showNotification('Commande non trouvée', 'error');
return;
}
// Ouvrir la console avec la commande pré-remplie
this.showAdHocConsole();
// Attendre que le modal soit rendu puis remplir les champs
setTimeout(() => {
this.loadHistoryCommand(cmd.command, cmd.target, cmd.module || 'shell', cmd.become || false);
}, 100);
}
/**
* Afficher les détails d'une exécution ad-hoc
*/
showAdhocExecutionDetail(commandId) {
const cmd = this.adhocHistory.find(c => c.id === commandId);
if (!cmd) {
this.showNotification('Commande non trouvée', 'error');
return;
}
const isSuccess = cmd.return_code === 0;
const statusColor = isSuccess ? 'text-green-400' : 'text-red-400';
const statusBg = isSuccess ? 'bg-green-900/30' : 'bg-red-900/30';
const statusText = isSuccess ? 'SUCCESS' : 'FAILED';
// Parser les résultats par hôte si disponibles
let hostsResults = [];
let okCount = 0, changedCount = 0, failedCount = 0;
if (cmd.stdout) {
// Essayer de parser les résultats Ansible
const lines = cmd.stdout.split('\n');
lines.forEach(line => {
const match = line.match(/^(\S+)\s*\|\s*(SUCCESS|CHANGED|FAILED|UNREACHABLE)/i);
if (match) {
const status = match[2].toUpperCase();
hostsResults.push({
host: match[1],
status: status,
line: line
});
if (status === 'SUCCESS') okCount++;
else if (status === 'CHANGED') changedCount++;
else failedCount++;
}
});
}
// Si pas de résultats parsés, utiliser les infos de base
if (hostsResults.length === 0 && cmd.hosts_count) {
okCount = isSuccess ? cmd.hosts_count : 0;
failedCount = isSuccess ? 0 : cmd.hosts_count;
}
const totalHosts = okCount + changedCount + failedCount || cmd.hosts_count || 1;
const successRate = totalHosts > 0 ? Math.round(((okCount + changedCount) / totalHosts) * 100) : 0;
// Formater la date
const date = cmd.executed_at ? new Date(cmd.executed_at) : new Date();
const dateStr = date.toLocaleDateString('fr-FR', { year: 'numeric', month: '2-digit', day: '2-digit' });
// Trouver la catégorie
const category = this.adhocCategories.find(c => c.name === cmd.category);
const catColor = category?.color || '#7c3aed';
this.showModal(`Log: Ad-hoc: ${cmd.command?.split(' ')[0] || 'commande'}`, `
Commande Ad-Hoc
Ad-hoc: ${this.escapeHtml(cmd.command?.split(' ')[0] || 'commande')}
${totalHosts} hôte(s)
Cible: ${this.escapeHtml(cmd.target || 'all')}
${cmd.duration ? cmd.duration.toFixed(2) + 's' : 'N/A'}
${statusText}
${dateStr}
# Code: ${cmd.return_code}
SUCCESS RATE
${successRate}%
Commande exécutée
${this.escapeHtml(cmd.command || 'N/A')}
Module: ${cmd.module || 'shell'}
${cmd.become ? ' Sudo: Oui ' : ''}
${this.escapeHtml(cmd.category || 'default')}
${hostsResults.length > 0 ? `
État des hôtes
Tous
OK
Changed
Failed
${hostsResults.map(hr => {
const hrColor = hr.status === 'SUCCESS' ? 'border-green-700/50 bg-green-900/20' :
hr.status === 'CHANGED' ? 'border-yellow-700/50 bg-yellow-900/20' :
'border-red-700/50 bg-red-900/20';
const hrTextColor = hr.status === 'SUCCESS' ? 'text-green-400' :
hr.status === 'CHANGED' ? 'text-yellow-400' : 'text-red-400';
return `
${this.escapeHtml(hr.host)}
${hr.status}
${this.escapeHtml(hr.line.substring(0, 60))}...
`;
}).join('')}
` : ''}
${cmd.stdout ? `
Sortie standard
${this.escapeHtml(cmd.stdout)}
` : ''}
${cmd.stderr ? `
Erreurs/Avertissements
${this.escapeHtml(cmd.stderr)}
` : ''}
Rejouer
Fermer
`);
}
/**
* Filtrer les hôtes dans le détail d'exécution
*/
filterAdhocDetailHosts(filter) {
// Mettre à jour les boutons
document.querySelectorAll('.adhoc-detail-filter').forEach(btn => {
if (btn.dataset.filter === filter) {
btn.classList.add('bg-gray-700', 'text-white');
btn.classList.remove('text-gray-400');
} else {
btn.classList.remove('bg-gray-700', 'text-white');
btn.classList.add('text-gray-400');
}
});
// Filtrer les résultats
document.querySelectorAll('.adhoc-host-result').forEach(el => {
const status = el.dataset.status;
if (filter === 'all' || status === filter || (filter === 'ok' && status === 'success')) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
});
}
// ===== MÉTHODES DU PLANIFICATEUR (SCHEDULES) =====
renderSchedules() {
const listContainer = document.getElementById('schedules-list');
const emptyState = document.getElementById('schedules-empty');
if (!listContainer) return;
// Filtrer les schedules
let filteredSchedules = [...this.schedules];
if (this.currentScheduleFilter === 'active') {
filteredSchedules = filteredSchedules.filter(s => s.enabled);
} else if (this.currentScheduleFilter === 'paused') {
filteredSchedules = filteredSchedules.filter(s => !s.enabled);
}
if (this.scheduleSearchQuery) {
const query = this.scheduleSearchQuery.toLowerCase();
filteredSchedules = filteredSchedules.filter(s =>
s.name.toLowerCase().includes(query) ||
s.playbook.toLowerCase().includes(query) ||
s.target.toLowerCase().includes(query)
);
}
// Mettre à jour les stats
this.updateSchedulesStats();
// Afficher l'état vide ou la liste
if (this.schedules.length === 0) {
listContainer.innerHTML = '';
emptyState?.classList.remove('hidden');
return;
}
emptyState?.classList.add('hidden');
if (filteredSchedules.length === 0) {
listContainer.innerHTML = `
Aucun schedule trouvé pour ces critères
`;
return;
}
listContainer.innerHTML = filteredSchedules.map(schedule => this.renderScheduleCard(schedule)).join('');
// Mettre à jour les prochaines exécutions
this.renderUpcomingExecutions();
}
renderScheduleCard(schedule) {
const statusClass = schedule.enabled ? 'active' : 'paused';
const statusChipClass = schedule.enabled ? 'active' : 'paused';
const statusText = schedule.enabled ? 'Actif' : 'En pause';
// Formater la prochaine exécution
let nextRunText = '--';
if (schedule.next_run_at) {
const nextRun = new Date(schedule.next_run_at);
nextRunText = this.formatRelativeTime(nextRun);
}
// Formater la dernière exécution
let lastRunHtml = '';
if (schedule.last_run_at) {
const lastStatusIcon = schedule.last_status === 'success' ? '✅' :
schedule.last_status === 'failed' ? '❌' :
schedule.last_status === 'running' ? '🔄' : '';
const lastRunDate = new Date(schedule.last_run_at);
lastRunHtml = `
| Dernier: ${lastStatusIcon} ${this.formatRelativeTime(lastRunDate)} `;
}
// Formater la récurrence
let recurrenceText = 'Exécution unique';
if (schedule.schedule_type === 'recurring' && schedule.recurrence) {
const rec = schedule.recurrence;
if (rec.type === 'daily') {
recurrenceText = `Tous les jours à ${rec.time}`;
} else if (rec.type === 'weekly') {
const days = (rec.days || []).map(d => ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'][d-1]).join(', ');
recurrenceText = `Chaque ${days} à ${rec.time}`;
} else if (rec.type === 'monthly') {
recurrenceText = `Le ${rec.day_of_month || 1} de chaque mois à ${rec.time}`;
} else if (rec.type === 'custom') {
recurrenceText = `Cron: ${rec.cron_expression}`;
}
}
// Tags
const tagsHtml = (schedule.tags || []).map(tag =>
`
${tag} `
).join('');
return `
${schedule.name}
${statusText}
${tagsHtml}
${schedule.description || ''}
${schedule.playbook}
${schedule.target}
${recurrenceText}
Prochaine: ${nextRunText}
${lastRunHtml}
${schedule.enabled ? `
` : `
`}
`;
}
updateSchedulesStats() {
const activeCount = this.schedules.filter(s => s.enabled).length;
const pausedCount = this.schedules.filter(s => !s.enabled).length;
const activeCountEl = document.getElementById('schedules-active-count');
if (activeCountEl) activeCountEl.textContent = activeCount;
const pausedCountEl = document.getElementById('schedules-paused-count');
if (pausedCountEl) pausedCountEl.textContent = pausedCount;
const failuresEl = document.getElementById('schedules-failures-24h');
if (failuresEl) failuresEl.textContent = this.schedulesStats.failures_24h || 0;
// Dashboard widget
const dashboardActiveEl = document.getElementById('dashboard-schedules-active');
if (dashboardActiveEl) dashboardActiveEl.textContent = activeCount;
const dashboardFailuresEl = document.getElementById('dashboard-schedules-failures');
if (dashboardFailuresEl) dashboardFailuresEl.textContent = this.schedulesStats.failures_24h || 0;
// Prochaine exécution
const activeSchedules = this.schedules.filter(s => s.enabled && s.next_run_at);
if (activeSchedules.length > 0) {
activeSchedules.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at));
const nextRun = new Date(activeSchedules[0].next_run_at);
const now = new Date();
const diffMs = nextRun - now;
const nextRunEl = document.getElementById('schedules-next-run');
const dashboardNextEl = document.getElementById('dashboard-schedules-next');
if (diffMs > 0) {
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
let nextText;
if (hours > 0) {
nextText = `${hours}h${mins}m`;
} else {
nextText = `${mins}min`;
}
if (nextRunEl) nextRunEl.textContent = nextText;
if (dashboardNextEl) dashboardNextEl.textContent = nextText;
} else {
if (nextRunEl) nextRunEl.textContent = 'Imminente';
if (dashboardNextEl) dashboardNextEl.textContent = 'Imminent';
}
} else {
const nextRunEl = document.getElementById('schedules-next-run');
const dashboardNextEl = document.getElementById('dashboard-schedules-next');
if (nextRunEl) nextRunEl.textContent = '--:--';
if (dashboardNextEl) dashboardNextEl.textContent = '--';
}
// Update dashboard upcoming schedules
this.updateDashboardUpcomingSchedules();
}
updateDashboardUpcomingSchedules() {
const container = document.getElementById('dashboard-upcoming-schedules');
if (!container) return;
const upcoming = this.schedules
.filter(s => s.enabled && s.next_run_at)
.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at))
.slice(0, 3);
if (upcoming.length === 0) {
container.innerHTML = '
Aucun schedule actif
';
return;
}
container.innerHTML = upcoming.map(s => {
const nextRun = new Date(s.next_run_at);
return `
${nextRun.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
`;
}).join('');
}
renderUpcomingExecutions() {
const container = document.getElementById('schedules-upcoming');
if (!container) return;
const activeSchedules = this.schedules
.filter(s => s.enabled && s.next_run_at)
.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at))
.slice(0, 5);
if (activeSchedules.length === 0) {
container.innerHTML = '
Aucune exécution planifiée
';
return;
}
container.innerHTML = activeSchedules.map(s => {
const nextRun = new Date(s.next_run_at);
return `
${s.name}
${s.playbook} → ${s.target}
${nextRun.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
${nextRun.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })}
`;
}).join('');
}
formatRelativeTime(date) {
const now = new Date();
const diffMs = date - now;
const absDiffMs = Math.abs(diffMs);
const isPast = diffMs < 0;
const mins = Math.floor(absDiffMs / (1000 * 60));
const hours = Math.floor(absDiffMs / (1000 * 60 * 60));
const days = Math.floor(absDiffMs / (1000 * 60 * 60 * 24));
if (days > 0) {
return isPast ? `il y a ${days}j` : `dans ${days}j`;
} else if (hours > 0) {
return isPast ? `il y a ${hours}h` : `dans ${hours}h`;
} else if (mins > 0) {
return isPast ? `il y a ${mins}min` : `dans ${mins}min`;
} else {
return isPast ? 'à l\'instant' : 'imminent';
}
}
filterSchedules(filter) {
this.currentScheduleFilter = filter;
// Mettre à jour les boutons
document.querySelectorAll('.schedule-filter-btn').forEach(btn => {
btn.classList.remove('active', 'bg-purple-600', 'text-white');
btn.classList.add('bg-gray-700', 'text-gray-300');
if (btn.dataset.filter === filter) {
btn.classList.add('active', 'bg-purple-600', 'text-white');
btn.classList.remove('bg-gray-700', 'text-gray-300');
}
});
this.renderSchedules();
}
searchSchedules(query) {
this.scheduleSearchQuery = query;
this.renderSchedules();
}
toggleScheduleView(view) {
const listView = document.getElementById('schedules-list-view');
const calendarView = document.getElementById('schedules-calendar-view');
if (view === 'calendar') {
listView?.classList.add('hidden');
calendarView?.classList.remove('hidden');
this.renderScheduleCalendar();
} else {
listView?.classList.remove('hidden');
calendarView?.classList.add('hidden');
}
}
async refreshSchedules() {
try {
const [schedulesData, statsData] = await Promise.all([
this.apiCall('/api/schedules'),
this.apiCall('/api/schedules/stats')
]);
this.schedules = schedulesData.schedules || [];
this.schedulesStats = statsData.stats || {};
this.schedulesUpcoming = statsData.upcoming || [];
this.renderSchedules();
this.showNotification('Schedules rafraîchis', 'success');
} catch (error) {
this.showNotification('Erreur lors du rafraîchissement', 'error');
}
}
async refreshSchedulesStats() {
try {
const statsData = await this.apiCall('/api/schedules/stats');
this.schedulesStats = statsData.stats || {};
this.schedulesUpcoming = statsData.upcoming || [];
this.updateSchedulesStats();
} catch (error) {
console.error('Erreur rafraîchissement stats schedules:', error);
}
}
// ===== ACTIONS SCHEDULES =====
async runScheduleNow(scheduleId) {
if (!confirm('Exécuter ce schedule immédiatement ?')) return;
try {
this.showLoading();
await this.apiCall(`/api/schedules/${scheduleId}/run`, { method: 'POST' });
this.showNotification('Schedule lancé', 'success');
} catch (error) {
this.showNotification('Erreur lors du lancement', 'error');
} finally {
this.hideLoading();
}
}
async pauseSchedule(scheduleId) {
try {
const result = await this.apiCall(`/api/schedules/${scheduleId}/pause`, { method: 'POST' });
const schedule = this.schedules.find(s => s.id === scheduleId);
if (schedule) schedule.enabled = false;
this.renderSchedules();
this.showNotification(`Schedule mis en pause`, 'success');
} catch (error) {
this.showNotification('Erreur lors de la mise en pause', 'error');
}
}
async resumeSchedule(scheduleId) {
try {
const result = await this.apiCall(`/api/schedules/${scheduleId}/resume`, { method: 'POST' });
const schedule = this.schedules.find(s => s.id === scheduleId);
if (schedule) schedule.enabled = true;
this.renderSchedules();
this.showNotification(`Schedule repris`, 'success');
} catch (error) {
this.showNotification('Erreur lors de la reprise', 'error');
}
}
async deleteSchedule(scheduleId) {
const schedule = this.schedules.find(s => s.id === scheduleId);
if (!schedule) return;
if (!confirm(`Supprimer le schedule "${schedule.name}" ?`)) return;
try {
await this.apiCall(`/api/schedules/${scheduleId}`, { method: 'DELETE' });
this.schedules = this.schedules.filter(s => s.id !== scheduleId);
this.renderSchedules();
this.showNotification(`Schedule supprimé`, 'success');
} catch (error) {
this.showNotification('Erreur lors de la suppression', 'error');
}
}
// ===== MODAL CRÉATION/ÉDITION SCHEDULE =====
async showCreateScheduleModal(prefilledPlaybook = null) {
this.editingScheduleId = null;
this.scheduleModalStep = 1;
// S'assurer que les playbooks sont chargés
if (!this.playbooks || this.playbooks.length === 0) {
try {
const playbooksData = await this.apiCall('/api/ansible/playbooks');
this.playbooks = playbooksData.playbooks || [];
} catch (error) {
console.error('Erreur chargement playbooks:', error);
}
}
const content = this.getScheduleModalContent(null, prefilledPlaybook);
this.showModal('Nouveau Schedule', content, 'schedule-modal');
}
async showEditScheduleModal(scheduleId) {
const schedule = this.schedules.find(s => s.id === scheduleId);
if (!schedule) return;
this.editingScheduleId = scheduleId;
this.scheduleModalStep = 1;
// S'assurer que les playbooks sont chargés
if (!this.playbooks || this.playbooks.length === 0) {
try {
const playbooksData = await this.apiCall('/api/ansible/playbooks');
this.playbooks = playbooksData.playbooks || [];
} catch (error) {
console.error('Erreur chargement playbooks:', error);
}
}
const content = this.getScheduleModalContent(schedule);
this.showModal(`Modifier: ${schedule.name}`, content, 'schedule-modal');
}
getScheduleModalContent(schedule = null, prefilledPlaybook = null) {
const isEdit = !!schedule;
const s = schedule || {};
// Options de playbooks
const playbookOptions = this.playbooks.map(p =>
`
${p.name} (${p.filename}) `
).join('');
// Options de groupes
const groupOptions = this.ansibleGroups.map(g =>
`
${g} `
).join('');
// Options d'hôtes
const hostOptions = this.ansibleHosts.map(h =>
`
${h.name} `
).join('');
// Récurrence
const rec = s.recurrence || {};
const daysChecked = (rec.days || [1]);
return `
Informations de base
Suivant
Quoi exécuter ?
Playbook *
-- Sélectionner un playbook --
${playbookOptions}
Groupe cible
all (tous les hôtes)
${groupOptions}
Hôte cible
${hostOptions}
Timeout (secondes)
Précédent
Suivant
Quand exécuter ?
Précédent
Suivant
Notifications
Notifications désactivées
Les notifications ntfy sont actuellement désactivées dans la configuration du serveur (NTFY_ENABLED=false). Les paramètres ci-dessous seront ignorés.
Précédent
${isEdit ? 'Enregistrer' : 'Créer le schedule'}
`;
}
async scheduleModalNextStep() {
if (this.scheduleModalStep < 4) {
this.scheduleModalStep++;
this.updateScheduleModalStep();
// Si on arrive à l'étape 4 (Notifications), vérifier si NTFY est activé
if (this.scheduleModalStep === 4) {
await this.checkNtfyStatus();
}
}
}
async checkNtfyStatus() {
try {
const config = await this.apiCall('/api/notifications/config');
const warningEl = document.getElementById('ntfy-disabled-warning');
if (warningEl) {
warningEl.classList.toggle('hidden', config.enabled !== false);
}
} catch (error) {
console.error('Erreur vérification statut NTFY:', error);
}
}
scheduleModalPrevStep() {
if (this.scheduleModalStep > 1) {
this.scheduleModalStep--;
this.updateScheduleModalStep();
}
}
updateScheduleModalStep() {
// Mettre à jour les indicateurs
document.querySelectorAll('.schedule-step-dot').forEach(dot => {
const step = parseInt(dot.dataset.step);
dot.classList.remove('active', 'completed');
if (step < this.scheduleModalStep) {
dot.classList.add('completed');
} else if (step === this.scheduleModalStep) {
dot.classList.add('active');
}
});
document.querySelectorAll('.schedule-step-connector').forEach((conn, i) => {
conn.classList.toggle('active', i < this.scheduleModalStep - 1);
});
// Afficher l'étape actuelle
document.querySelectorAll('.schedule-modal-step').forEach(step => {
step.classList.remove('active');
if (parseInt(step.dataset.step) === this.scheduleModalStep) {
step.classList.add('active');
}
});
}
toggleScheduleTargetType(type) {
document.getElementById('schedule-group-select')?.classList.toggle('hidden', type === 'host');
document.getElementById('schedule-host-select')?.classList.toggle('hidden', type === 'group');
}
toggleScheduleType(type) {
document.getElementById('schedule-recurring-options')?.classList.toggle('hidden', type === 'once');
document.getElementById('schedule-once-options')?.classList.toggle('hidden', type === 'recurring');
}
updateRecurrenceOptions() {
const type = document.getElementById('schedule-recurrence-type')?.value;
document.getElementById('recurrence-time')?.classList.toggle('hidden', type === 'custom');
document.getElementById('recurrence-weekly-days')?.classList.toggle('hidden', type !== 'weekly');
document.getElementById('recurrence-monthly-day')?.classList.toggle('hidden', type !== 'monthly');
document.getElementById('recurrence-cron')?.classList.toggle('hidden', type !== 'custom');
}
async validateCronExpression(expression) {
const container = document.getElementById('cron-validation');
if (!container || !expression.trim()) {
if (container) container.innerHTML = '';
return;
}
try {
const result = await this.apiCall(`/api/schedules/validate-cron?expression=${encodeURIComponent(expression)}`);
if (result.valid) {
container.innerHTML = `
Expression valide
Prochaines: ${result.next_runs?.slice(0, 3).map(r => new Date(r).toLocaleString('fr-FR')).join(', ')}
`;
} else {
container.innerHTML = `
${result.error}
`;
}
} catch (error) {
container.innerHTML = `
Erreur de validation
`;
}
}
async saveSchedule() {
const name = document.getElementById('schedule-name')?.value.trim();
const description = document.getElementById('schedule-description')?.value.trim();
const playbook = document.getElementById('schedule-playbook')?.value;
const targetType = document.querySelector('input[name="schedule-target-type"]:checked')?.value || 'group';
const targetGroup = document.getElementById('schedule-target-group')?.value;
const targetHost = document.getElementById('schedule-target-host')?.value;
const timeout = parseInt(document.getElementById('schedule-timeout')?.value) || 3600;
const scheduleType = document.querySelector('input[name="schedule-type"]:checked')?.value || 'recurring';
const enabled = document.getElementById('schedule-enabled')?.checked ?? true;
const notificationType = document.querySelector('input[name="schedule-notification-type"]:checked')?.value || 'all';
// Validation
if (!name || name.length < 3) {
this.showNotification('Le nom doit faire au moins 3 caractères', 'error');
return;
}
if (!playbook) {
this.showNotification('Veuillez sélectionner un playbook', 'error');
return;
}
// Construire les tags
const tags = Array.from(document.querySelectorAll('.schedule-tag-checkbox:checked')).map(cb => cb.value);
// Construire la récurrence
let recurrence = null;
let startAt = null;
if (scheduleType === 'recurring') {
const recType = document.getElementById('schedule-recurrence-type')?.value || 'daily';
const time = document.getElementById('schedule-time')?.value || '02:00';
recurrence = { type: recType, time };
if (recType === 'weekly') {
recurrence.days = Array.from(document.querySelectorAll('.schedule-day-checkbox:checked')).map(cb => parseInt(cb.value));
if (recurrence.days.length === 0) recurrence.days = [1];
} else if (recType === 'monthly') {
recurrence.day_of_month = parseInt(document.getElementById('schedule-day-of-month')?.value) || 1;
} else if (recType === 'custom') {
recurrence.cron_expression = document.getElementById('schedule-cron')?.value;
if (!recurrence.cron_expression) {
this.showNotification('Veuillez entrer une expression cron', 'error');
return;
}
}
} else {
const startAtValue = document.getElementById('schedule-start-at')?.value;
if (startAtValue) {
startAt = new Date(startAtValue).toISOString();
}
}
const payload = {
name,
description: description || null,
playbook,
target_type: targetType,
target: targetType === 'host' ? targetHost : targetGroup,
timeout,
schedule_type: scheduleType,
recurrence,
start_at: startAt,
enabled,
tags,
notification_type: notificationType
};
try {
this.showLoading();
if (this.editingScheduleId) {
await this.apiCall(`/api/schedules/${this.editingScheduleId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
await this.apiCall('/api/schedules', { method: 'POST', body: JSON.stringify(payload) });
}
this.closeModal();
await this.refreshSchedules();
} catch (error) {
this.showNotification(error.message || 'Erreur lors de la sauvegarde', 'error');
} finally {
this.hideLoading();
}
}
async showScheduleHistory(scheduleId) {
const schedule = this.schedules.find(s => s.id === scheduleId);
if (!schedule) return;
try {
const result = await this.apiCall(`/api/schedules/${scheduleId}/runs?limit=50`);
const runs = result.runs || [];
let content;
if (runs.length === 0) {
content = `
Aucune exécution enregistrée
Le schedule n'a pas encore été exécuté.
`;
} else {
content = `
${runs.length} exécution(s) - Taux de succès: ${schedule.run_count > 0 ? Math.round((schedule.success_count / schedule.run_count) * 100) : 0}%
${runs.map(run => {
const startedAt = new Date(run.started_at);
const statusClass = run.status === 'success' ? 'success' :
run.status === 'failed' ? 'failed' :
run.status === 'running' ? 'running' : 'scheduled';
const statusIcon = run.status === 'success' ? 'check-circle' :
run.status === 'failed' ? 'times-circle' :
run.status === 'running' ? 'spinner fa-spin' : 'clock';
return `
${run.status}
${startedAt.toLocaleString('fr-FR')}
${run.duration_seconds ? `
Durée: ${run.duration_seconds.toFixed(1)}s
` : ''}
${run.hosts_impacted > 0 ? `
${run.hosts_impacted} hôte(s) ` : ''}
${run.task_id ? `
Voir tâche ` : ''}
`;
}).join('')}
`;
}
this.showModal(`Historique: ${schedule.name}`, content);
} catch (error) {
this.showNotification('Erreur lors du chargement de l\'historique', 'error');
}
}
// ===== CALENDRIER DES SCHEDULES =====
renderScheduleCalendar() {
const grid = document.getElementById('schedule-calendar-grid');
const titleEl = document.getElementById('schedule-calendar-title');
if (!grid || !titleEl) return;
const year = this.scheduleCalendarMonth.getFullYear();
const month = this.scheduleCalendarMonth.getMonth();
// Titre
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
titleEl.textContent = `${monthNames[month]} ${year}`;
// Premier jour du mois
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Ajuster pour commencer par Lundi (0 = Dimanche dans JS)
let startDay = firstDay.getDay() - 1;
if (startDay < 0) startDay = 6;
// Générer les jours
const days = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
// Jours du mois précédent
const prevMonth = new Date(year, month, 0);
for (let i = startDay - 1; i >= 0; i--) {
days.push({
date: new Date(year, month - 1, prevMonth.getDate() - i),
otherMonth: true
});
}
// Jours du mois actuel
for (let d = 1; d <= lastDay.getDate(); d++) {
const date = new Date(year, month, d);
days.push({
date,
otherMonth: false,
isToday: date.getTime() === today.getTime()
});
}
// Jours du mois suivant
const remainingDays = 42 - days.length;
for (let d = 1; d <= remainingDays; d++) {
days.push({
date: new Date(year, month + 1, d),
otherMonth: true
});
}
// Générer le HTML
grid.innerHTML = days.map(day => {
const dateStr = day.date.toISOString().split('T')[0];
const classes = ['schedule-calendar-day'];
if (day.otherMonth) classes.push('other-month');
if (day.isToday) classes.push('today');
// Événements pour ce jour (simplifiés - prochaines exécutions)
const events = this.schedulesUpcoming.filter(s => {
if (!s.next_run_at) return false;
const runDate = new Date(s.next_run_at).toISOString().split('T')[0];
return runDate === dateStr;
});
return `
${day.date.getDate()}
${events.slice(0, 2).map(e => `
${e.schedule_name}
`).join('')}
${events.length > 2 ? `
+${events.length - 2}
` : ''}
`;
}).join('');
}
prevCalendarMonth() {
this.scheduleCalendarMonth.setMonth(this.scheduleCalendarMonth.getMonth() - 1);
this.renderScheduleCalendar();
}
nextCalendarMonth() {
this.scheduleCalendarMonth.setMonth(this.scheduleCalendarMonth.getMonth() + 1);
this.renderScheduleCalendar();
}
// ===== FIN DES MÉTHODES PLANIFICATEUR =====
closeModal() {
const modal = document.getElementById('modal');
anime({
targets: '#modal .glass-card',
scale: [1, 0.8],
opacity: [1, 0],
duration: 200,
easing: 'easeInExpo',
complete: () => {
modal.classList.add('hidden');
}
});
}
showLoading() {
document.getElementById('loading-overlay').classList.remove('hidden');
}
hideLoading() {
document.getElementById('loading-overlay').classList.add('hidden');
}
showNotification(message, type = 'info') {
// Persister en alerte (fire-and-forget)
try {
const msgStr = String(message || '');
const lower = msgStr.toLowerCase();
let category = 'notification';
if (lower.includes('métrique') || lower.includes('metrics')) {
category = 'metric';
} else if (lower.includes('playbook')) {
category = 'playbook';
} else if (lower.includes('schedule')) {
category = 'schedule';
} else if (lower.includes('bootstrap')) {
category = 'bootstrap';
} else if (lower.includes('task') || lower.includes('tâche')) {
category = 'task';
}
const title = type === 'success' ? 'Succès' : (type === 'warning' ? 'Avertissement' : (type === 'error' ? 'Erreur' : 'Info'));
this.apiCall('/api/alerts', {
method: 'POST',
body: JSON.stringify({
category,
title,
level: type,
message: msgStr,
source: 'ui'
})
}).catch(() => {});
} catch (e) {
// ignore
}
const notification = document.createElement('div');
notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${
type === 'success' ? 'bg-green-600' :
type === 'warning' ? 'bg-yellow-600' :
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
} text-white`;
notification.innerHTML = `
${message}
`;
document.body.appendChild(notification);
// Animate in
anime({
targets: notification,
translateX: [300, 0],
opacity: [0, 1],
duration: 300,
easing: 'easeOutExpo'
});
// Remove after 3 seconds
setTimeout(() => {
anime({
targets: notification,
translateX: [0, 300],
opacity: [1, 0],
duration: 300,
easing: 'easeInExpo',
complete: () => {
notification.remove();
}
});
}, 3000);
}
// ===== ALERTES =====
updateAlertsBadge() {
const badge = document.getElementById('alerts-badge');
if (!badge) return;
const count = Number(this.alertsUnread || 0);
if (count > 0) {
badge.textContent = count > 99 ? '99+' : String(count);
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
async refreshAlerts() {
try {
const data = await this.apiCall('/api/alerts?limit=200&offset=0');
// L'API retourne {alerts: [...], count: N}
this.alerts = Array.isArray(data) ? data : (data.alerts || []);
this.renderAlerts();
} catch (error) {
console.error('Erreur chargement alertes:', error);
}
}
async refreshAlertsCount() {
try {
const data = await this.apiCall('/api/alerts/unread-count');
this.alertsUnread = data.unread || 0;
this.updateAlertsBadge();
} catch (error) {
console.error('Erreur chargement compteur alertes:', error);
}
}
handleAlertCreated(alert) {
if (!alert) return;
this.alerts = [alert, ...(this.alerts || [])].slice(0, 200);
this.renderAlerts();
this.refreshAlertsCount();
}
async markAlertRead(alertId) {
try {
await this.apiCall(`/api/alerts/${alertId}/read`, { method: 'POST' });
const idx = (this.alerts || []).findIndex(a => a.id === alertId);
if (idx !== -1) {
this.alerts[idx].read_at = new Date().toISOString();
}
this.renderAlerts();
this.refreshAlertsCount();
} catch (error) {
console.error('Erreur marquer alerte lue:', error);
}
}
async markAllAlertsRead() {
try {
await this.apiCall('/api/alerts/mark-all-read', { method: 'POST' });
(this.alerts || []).forEach(a => {
a.read_at = a.read_at || new Date().toISOString();
});
this.renderAlerts();
this.refreshAlertsCount();
} catch (error) {
console.error('Erreur marquer toutes alertes lues:', error);
}
}
renderAlerts() {
const container = document.getElementById('alerts-container');
if (!container) return;
const items = this.alerts || [];
if (items.length === 0) {
container.innerHTML = `
Aucune alerte pour le moment
`;
return;
}
container.innerHTML = items.map(a => {
const created = a.created_at ? new Date(a.created_at).toLocaleString('fr-FR') : '--';
const isRead = Boolean(a.read_at || a.read);
const pill = isRead
? '
Lu '
: '
Non lu ';
const cat = a.category || 'notification';
const catBadge = `
${this.escapeHtml(cat)} `;
const title = a.title ? `
${this.escapeHtml(a.title)}
` : '';
const msg = `
${this.escapeHtml(a.message || '')}
`;
const meta = `
${created} ${catBadge}${a.source ? `[${this.escapeHtml(a.source)}] ` : ''}${pill}
`;
const actions = !isRead
? `
Marquer lu`
: '';
const border = isRead ? 'border-gray-700/60' : 'border-red-700/60';
const bg = isRead ? 'bg-gray-800/40' : 'bg-gray-800/60';
return `
${title}
${msg}
${meta}
${actions}
`;
}).join('');
}
// =====================================================
// Terminal SSH - Web Terminal Feature
// =====================================================
async checkTerminalFeatureStatus() {
try {
const data = await this.apiCall('/api/terminal/status');
this.terminalFeatureAvailable = Boolean(data && data.available);
return data;
} catch (e) {
console.warn('Terminal feature check failed:', e);
this.terminalFeatureAvailable = false;
}
return { available: false };
}
async openTerminal(hostId, hostName, hostIp) {
// Anti-double-click: reuse pending promise
if (this.terminalOpeningPromise) {
return this.terminalOpeningPromise;
}
// Check if terminal feature is available
if (!this.terminalFeatureAvailable) {
const status = await this.checkTerminalFeatureStatus();
if (!status.available) {
this.showNotification('Terminal SSH non disponible: ttyd n\'est pas installé', 'error');
return;
}
// Store heartbeat interval from server config
if (status.heartbeat_interval_seconds) {
this.terminalHeartbeatIntervalMs = status.heartbeat_interval_seconds * 1000;
}
}
// Show loading state
this.showTerminalDrawer(hostName, hostIp, true);
// Create promise to prevent double-click
this.terminalOpeningPromise = (async () => {
try {
const response = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, {
method: 'POST',
body: JSON.stringify({ mode: 'embedded' })
});
// Check if this is a session limit error (429 with rich response)
if (response.error === 'SESSION_LIMIT') {
this.closeTerminalDrawer();
this.showSessionLimitModal(response, hostId, hostName, hostIp);
return;
}
this.terminalSession = response;
// Update drawer with terminal iframe
this.updateTerminalDrawer(response);
// Start heartbeat
this.startTerminalHeartbeat();
// Log if session was reused
if (response.reused) {
console.log('Terminal session reused:', response.session_id);
}
} catch (e) {
console.error('Failed to open terminal:', e);
// Check if error response contains session limit info
if (e.response && e.response.error === 'SESSION_LIMIT') {
this.closeTerminalDrawer();
this.showSessionLimitModal(e.response, hostId, hostName, hostIp);
} else {
this.showNotification(`Erreur terminal: ${e.message}`, 'error');
this.closeTerminalDrawer();
}
} finally {
this.terminalOpeningPromise = null;
}
})();
return this.terminalOpeningPromise;
}
async openTerminalPopout(hostId, hostName, hostIp) {
// Check if terminal feature is available
if (!this.terminalFeatureAvailable) {
const status = await this.checkTerminalFeatureStatus();
if (!status.available) {
this.showNotification('Terminal SSH non disponible: ttyd n\'est pas installé', 'error');
return;
}
}
this.showNotification('Création de la session terminal...', 'info');
try {
const session = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, {
method: 'POST',
body: JSON.stringify({ mode: 'popout' })
});
// Open in popup window
const popupUrl = session.url;
const popupFeatures = 'width=900,height=600,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no';
const popup = window.open(popupUrl, `terminal_${session.session_id}`, popupFeatures);
if (!popup || popup.closed) {
// Popup blocked - open in new tab instead
window.open(popupUrl, '_blank');
this.showNotification('Terminal ouvert dans un nouvel onglet (popup bloqué)', 'warning');
}
} catch (e) {
console.error('Failed to open terminal popout:', e);
this.showNotification(`Erreur terminal: ${e.message}`, 'error');
}
}
showTerminalDrawer(hostName, hostIp, loading = false) {
this.terminalDrawerOpen = true;
// Create drawer if it doesn't exist
let drawer = document.getElementById('terminalDrawer');
if (!drawer) {
drawer = document.createElement('div');
drawer.id = 'terminalDrawer';
drawer.className = 'terminal-drawer';
document.body.appendChild(drawer);
}
const loadingContent = loading ? `
Connexion à ${this.escapeHtml(hostName)}...
` : '';
drawer.innerHTML = `
`;
// Animate in
requestAnimationFrame(() => {
drawer.classList.add('open');
});
// Add keyboard handlers (Escape to close, Ctrl+R for history)
this._terminalEscHandler = (e) => {
if (e.key === 'Escape' && this.terminalDrawerOpen) {
// If history panel is open, close it first
if (this.terminalHistoryPanelOpen) {
this.closeTerminalHistoryPanel();
e.preventDefault();
return;
}
this.closeTerminalDrawer();
}
// Ctrl+R to open/focus history search
if ((e.ctrlKey || e.metaKey) && e.key === 'r' && this.terminalDrawerOpen) {
e.preventDefault();
this.openTerminalHistoryPanel();
}
};
document.addEventListener('keydown', this._terminalEscHandler);
}
updateTerminalDrawer(session) {
const body = document.getElementById('terminalDrawerBody');
if (!body) return;
const statusBadge = document.querySelector('.terminal-status-badge');
if (statusBadge) {
statusBadge.textContent = 'Connecté';
statusBadge.classList.remove('connecting');
statusBadge.classList.add('online');
}
// Create iframe for terminal
// Use embed mode so the connect page can hide its own header/pwa hint
// and let the drawer UI be the single source of controls.
const embeddedUrl = (() => {
try {
const url = new URL(session.url, window.location.origin);
url.searchParams.set('embed', '1');
return url.toString();
} catch (e) {
// Fallback for relative URLs
return session.url + (session.url.includes('?') ? '&' : '?') + 'embed=1';
}
})();
body.innerHTML = `
`;
// Start countdown timer
this.startTerminalTimer(session.ttl_seconds);
}
onTerminalIframeLoad() {
const iframe = document.getElementById('terminalIframe');
if (iframe) {
iframe.focus();
}
}
startTerminalTimer(seconds) {
const timerEl = document.getElementById('terminalTimer');
if (!timerEl) return;
let remaining = seconds;
const updateTimer = () => {
if (remaining <= 0) {
timerEl.textContent = 'Session expirée';
timerEl.classList.add('expired');
return;
}
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
timerEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
if (remaining < 60) {
timerEl.classList.add('warning');
}
remaining--;
};
updateTimer();
this._terminalTimerInterval = setInterval(updateTimer, 1000);
}
async closeTerminalDrawer(options = {}) {
const { closeSession = true } = options;
const drawer = document.getElementById('terminalDrawer');
if (drawer) {
drawer.classList.remove('open');
setTimeout(() => {
drawer.remove();
}, 300);
}
// Clean up timer
if (this._terminalTimerInterval) {
clearInterval(this._terminalTimerInterval);
this._terminalTimerInterval = null;
}
// Stop heartbeat
this.stopTerminalHeartbeat();
// Remove escape handler
if (this._terminalEscHandler) {
document.removeEventListener('keydown', this._terminalEscHandler);
this._terminalEscHandler = null;
}
// Close session on server (best effort)
if (closeSession && this.terminalSession) {
try {
await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}`, {
method: 'DELETE'
});
} catch (e) {
console.warn('Failed to close terminal session:', e);
}
this.terminalSession = null;
}
this.terminalDrawerOpen = false;
}
// ===== TERMINAL CLEANUP HANDLERS =====
setupTerminalCleanupHandlers() {
// Handle page unload (browser close, navigation away)
window.addEventListener('beforeunload', () => {
this.sendTerminalCloseBeacon();
});
// Handle page hide (mobile tab switch, etc.)
window.addEventListener('pagehide', () => {
this.sendTerminalCloseBeacon();
});
// Handle visibility change (tab hidden)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// Stop heartbeat when tab is hidden
this.stopTerminalHeartbeat();
} else if (document.visibilityState === 'visible' && this.terminalSession && this.terminalDrawerOpen) {
// Resume heartbeat when tab becomes visible again
this.startTerminalHeartbeat();
}
});
}
sendTerminalCloseBeacon() {
if (!this.terminalSession) return;
const sessionId = this.terminalSession.session_id;
const url = `${this.apiBase}/api/terminal/sessions/${sessionId}/close-beacon`;
// Use sendBeacon for reliable delivery during page unload
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify({ reason: 'client_close' })], { type: 'application/json' });
navigator.sendBeacon(url, blob);
console.log('Terminal close beacon sent via sendBeacon');
} else {
// Fallback: synchronous XHR (blocking, but ensures delivery)
try {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, false); // synchronous
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ reason: 'client_close' }));
console.log('Terminal close beacon sent via XHR');
} catch (e) {
console.warn('Failed to send terminal close beacon:', e);
}
}
// Clear session reference
this.terminalSession = null;
}
// ===== TERMINAL HEARTBEAT =====
startTerminalHeartbeat() {
this.stopTerminalHeartbeat(); // Clear any existing
if (!this.terminalSession) return;
this.terminalHeartbeatInterval = setInterval(async () => {
if (!this.terminalSession || !this.terminalDrawerOpen) {
this.stopTerminalHeartbeat();
return;
}
try {
await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}/heartbeat?token=${encodeURIComponent(this.terminalSession.token)}`, {
method: 'POST'
});
} catch (e) {
console.warn('Terminal heartbeat failed:', e);
// Session might be expired - don't stop heartbeat, let server handle it
}
}, this.terminalHeartbeatIntervalMs);
console.log('Terminal heartbeat started');
}
stopTerminalHeartbeat() {
if (this.terminalHeartbeatInterval) {
clearInterval(this.terminalHeartbeatInterval);
this.terminalHeartbeatInterval = null;
console.log('Terminal heartbeat stopped');
}
}
// ===== TERMINAL SESSION LIMIT MODAL =====
showSessionLimitModal(limitError, targetHostId, targetHostName, targetHostIp) {
// Remove existing modal if any
const existingModal = document.getElementById('sessionLimitModal');
if (existingModal) existingModal.remove();
const sessionsHtml = limitError.active_sessions.map(s => `
${this.escapeHtml(s.host_name)}
${s.mode}
${this.formatDuration(s.age_seconds)}
Fermer
`).join('');
const canReuseHtml = limitError.can_reuse ? `
Une session existe déjà pour cet hôte. Vous pouvez la réutiliser.
Réutiliser la session existante
` : '';
const modal = document.createElement('div');
modal.id = 'sessionLimitModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
Vous avez atteint la limite de ${limitError.max_active} sessions actives.
Fermez une session existante pour en ouvrir une nouvelle.
${canReuseHtml}
Sessions actives (${limitError.current_count}/${limitError.max_active})
${sessionsHtml}
Fermer la plus ancienne et réessayer
`;
document.body.appendChild(modal);
requestAnimationFrame(() => modal.classList.add('show'));
}
closeSessionLimitModal() {
const modal = document.getElementById('sessionLimitModal');
if (modal) {
modal.classList.remove('show');
setTimeout(() => modal.remove(), 300);
}
}
async closeSessionFromModal(sessionId) {
try {
await this.apiCall(`/api/terminal/sessions/${sessionId}`, { method: 'DELETE' });
// Remove item from modal
const item = document.querySelector(`[data-session-id="${sessionId}"]`);
if (item) {
item.style.opacity = '0.5';
item.querySelector('button').disabled = true;
item.querySelector('button').innerHTML = '
Fermée';
}
this.showNotification('Session fermée', 'success');
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
async closeOldestSessionFromModal(targetHostId, targetHostName, targetHostIp) {
try {
// Get current sessions
const sessions = await this.apiCall('/api/terminal/sessions');
if (sessions.sessions && sessions.sessions.length > 0) {
// Find oldest (last in list since sorted by created_at desc)
const oldest = sessions.sessions[sessions.sessions.length - 1];
await this.apiCall(`/api/terminal/sessions/${oldest.session_id}`, { method: 'DELETE' });
this.showNotification(`Session fermée: ${oldest.host_name}`, 'success');
}
// Close modal and retry
this.closeSessionLimitModal();
await this.openTerminal(targetHostId, targetHostName, targetHostIp);
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, 'error');
}
}
async reuseSessionFromModal(sessionId, targetHostId, targetHostName, targetHostIp) {
this.closeSessionLimitModal();
// Just retry - the server will return the reusable session
await this.openTerminal(targetHostId, targetHostName, targetHostIp);
}
formatDuration(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
openCurrentTerminalPopout() {
if (!this.terminalSession) {
this.showNotification('Aucune session terminal active', 'warning');
return;
}
// If the drawer iframe is currently connected, ttyd is started with --once so
// we must release the existing client before opening the popout.
if (this.terminalDrawerOpen) {
try {
const iframe = document.getElementById('terminalIframe');
if (iframe) {
iframe.src = 'about:blank';
}
} catch (e) {
// Ignore
}
}
const sessionId = this.terminalSession.session_id;
const token = this.terminalSession.token;
const popupUrl = (sessionId && token)
? `/api/terminal/popout/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(token)}`
: this.terminalSession.url;
const popupFeatures = 'width=900,height=600,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no';
window.open(popupUrl, `terminal_${this.terminalSession.session_id}`, popupFeatures);
// Close the drawer UI but keep the backend session alive for the popout.
if (this.terminalDrawerOpen) {
this.closeTerminalDrawer({ closeSession: false });
}
}
copySSHCommand() {
if (!this.terminalSession) {
this.showNotification('Aucune session terminal active', 'warning');
return;
}
const cmd = `ssh automation@${this.terminalSession.host.ip}`;
this.copyTextToClipboard(cmd)
.then(() => {
this.showNotification(`Commande copiée: ${cmd}`, 'success');
})
.catch(() => {
this.showNotification('Impossible de copier dans le presse-papier', 'error');
});
}
async reconnectTerminal() {
if (!this.terminalSession) {
this.showNotification('Aucune session terminal active', 'warning');
return;
}
const { host } = this.terminalSession;
// Close current session
await this.closeTerminalDrawer();
// Reopen
await this.openTerminal(host.id, host.name, host.ip);
}
// ===== TERMINAL COMMAND HISTORY =====
async logTerminalCommand(command) {
try {
if (!this.terminalSession) return;
const sessionId = this.terminalSession.session_id;
const token = this.terminalSession.token;
const cmd = String(command ?? '').trim();
if (!cmd) return;
const endpoint = `/api/terminal/sessions/${encodeURIComponent(sessionId)}/command?token=${encodeURIComponent(token)}`;
await this.apiCall(endpoint, {
method: 'POST',
body: JSON.stringify({ command: cmd })
});
} catch (e) {
// Best-effort: do not disrupt UX if logging fails
}
}
async toggleTerminalHistory() {
if (this.terminalHistoryPanelOpen) {
this.closeTerminalHistoryPanel();
} else {
this.openTerminalHistoryPanel();
}
}
async openTerminalHistoryPanel() {
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('terminalHistoryBtn');
if (!panel) return;
this.terminalHistoryPanelOpen = true;
this.terminalHistorySelectedIndex = -1;
panel.style.display = 'flex';
panel.classList.add('open');
btn?.classList.add('active');
// Load history if not loaded
if (this.terminalCommandHistory.length === 0) {
await this.loadTerminalCommandHistory();
}
// Focus search input
const searchInput = document.getElementById('terminalHistorySearch');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
}
closeTerminalHistoryPanel() {
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('terminalHistoryBtn');
if (!panel) return;
this.terminalHistoryPanelOpen = false;
this.terminalHistorySelectedIndex = -1;
panel.classList.remove('open');
setTimeout(() => {
panel.style.display = 'none';
}, 200);
btn?.classList.remove('active');
// Return focus to terminal
const iframe = document.getElementById('terminalIframe');
if (iframe) {
iframe.focus();
}
}
async loadTerminalCommandHistory(query = '') {
if (!this.terminalSession) return;
const listEl = document.getElementById('terminalHistoryList');
if (!listEl) return;
this.terminalHistoryLoading = true;
listEl.innerHTML = '
Chargement...
';
try {
const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false;
const hostId = this.terminalSession.host.id;
const timeFilter = this.terminalHistoryTimeFilter;
// Build query params
const params = new URLSearchParams();
params.set('limit', '100');
if (query) params.set('query', query);
// Add time filter
if (timeFilter !== 'all') {
const now = new Date();
let since;
switch (timeFilter) {
case 'today':
since = new Date(now.getFullYear(), now.getMonth(), now.getDate());
break;
case 'week':
since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case 'month':
since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
}
if (since) {
params.set('since', since.toISOString());
}
}
let endpoint;
if (allHosts) {
endpoint = `/api/terminal/command-history?${params.toString()}`;
} else {
// Use shell-history to fetch real commands from the remote host via SSH
endpoint = `/api/terminal/${hostId}/shell-history?${params.toString()}`;
}
const response = await this.apiCall(endpoint);
let commands = response.commands || [];
// Client-side time filtering if API doesn't support it
if (timeFilter !== 'all') {
const now = new Date();
let cutoff;
switch (timeFilter) {
case 'today':
cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate());
break;
case 'week':
cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case 'month':
cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
}
if (cutoff) {
commands = commands.filter(cmd => {
const cmdDate = new Date(cmd.last_used || cmd.created_at);
return cmdDate >= cutoff;
});
}
}
this.terminalCommandHistory = commands;
this.terminalHistorySelectedIndex = -1;
this.renderTerminalHistory();
} catch (e) {
console.error('Failed to load terminal history:', e);
listEl.innerHTML = '
Erreur de chargement
';
} finally {
this.terminalHistoryLoading = false;
}
}
renderTerminalHistory(highlightQuery = '') {
const listEl = document.getElementById('terminalHistoryList');
if (!listEl) return;
const query = highlightQuery || this.terminalHistorySearchQuery || '';
if (this.terminalCommandHistory.length === 0) {
const emptyMessage = query
? `Aucun résultat pour "${this.escapeHtml(query)}"`
: 'Aucune commande dans l\'historique';
listEl.innerHTML = `
${emptyMessage}
${query ? 'Essayez une recherche différente ' : ''}
`;
return;
}
const items = this.terminalCommandHistory.map((cmd, index) => {
const command = cmd.command || '';
const timeAgo = this.formatRelativeTime(cmd.last_used || cmd.created_at);
const execCount = cmd.execution_count || 1;
const hostName = cmd.host_name || '';
const isSelected = index === this.terminalHistorySelectedIndex;
// Highlight search query in command
let displayCommand = this.escapeHtml(command.length > 80 ? command.substring(0, 80) + '...' : command);
if (query) {
const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi');
displayCommand = displayCommand.replace(regex, '
$1 ');
}
return `
${displayCommand}
${timeAgo}
${execCount > 1 ? `×${execCount} ` : ''}
${hostName ? `${this.escapeHtml(hostName)} ` : ''}
`;
}).join('');
listEl.innerHTML = items;
// Scroll selected item into view
if (this.terminalHistorySelectedIndex >= 0) {
const selectedEl = listEl.querySelector('.terminal-history-item.selected');
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
handleHistorySearchKeydown(event) {
const key = event.key;
const historyLength = this.terminalCommandHistory.length;
switch (key) {
case 'ArrowDown':
event.preventDefault();
if (historyLength > 0) {
this.terminalHistorySelectedIndex = Math.min(
this.terminalHistorySelectedIndex + 1,
historyLength - 1
);
this.renderTerminalHistory();
}
break;
case 'ArrowUp':
event.preventDefault();
if (historyLength > 0) {
this.terminalHistorySelectedIndex = Math.max(
this.terminalHistorySelectedIndex - 1,
0
);
this.renderTerminalHistory();
}
break;
case 'Enter':
event.preventDefault();
if (this.terminalHistorySelectedIndex >= 0) {
this.selectAndInsertHistoryCommand(this.terminalHistorySelectedIndex);
} else if (historyLength > 0) {
this.selectAndInsertHistoryCommand(0);
}
break;
case 'Escape':
event.preventDefault();
this.closeTerminalHistoryPanel();
break;
case 'Tab':
// Tab to cycle through results
event.preventDefault();
if (event.shiftKey) {
this.terminalHistorySelectedIndex = Math.max(this.terminalHistorySelectedIndex - 1, 0);
} else {
this.terminalHistorySelectedIndex = Math.min(this.terminalHistorySelectedIndex + 1, historyLength - 1);
}
this.renderTerminalHistory();
break;
}
}
searchTerminalHistory(query) {
this.terminalHistorySearchQuery = query;
this.terminalHistorySelectedIndex = -1;
// Debounce search
if (this._historySearchTimeout) {
clearTimeout(this._historySearchTimeout);
}
this._historySearchTimeout = setTimeout(() => {
this.loadTerminalCommandHistory(query);
}, 250);
}
clearTerminalHistorySearch() {
const input = document.getElementById('terminalHistorySearch');
if (input) {
input.value = '';
input.focus();
}
this.terminalHistorySearchQuery = '';
this.terminalHistorySelectedIndex = -1;
this.loadTerminalCommandHistory('');
}
setHistoryTimeFilter(value) {
this.terminalHistoryTimeFilter = value;
this.terminalHistorySelectedIndex = -1;
this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
}
toggleHistoryScope() {
this.terminalHistorySelectedIndex = -1;
this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
}
selectAndInsertHistoryCommand(index) {
const cmd = this.terminalCommandHistory[index];
if (!cmd) return;
const command = cmd.command || '';
// Copy to clipboard and show notification
this.copyTextToClipboard(command).then(() => {
this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V', 'success');
// Best-effort log
this.logTerminalCommand(command);
// Close history panel and focus terminal
this.closeTerminalHistoryPanel();
// Focus the iframe
const iframe = document.getElementById('terminalIframe');
if (iframe && iframe.contentWindow) {
iframe.focus();
iframe.contentWindow.focus();
}
}).catch(() => {
this.showNotification('Commande: ' + command, 'info');
});
}
executeHistoryCommand(index) {
const cmd = this.terminalCommandHistory[index];
if (!cmd) return;
const command = cmd.command || '';
// Copy command + newline to execute it
this.copyTextToClipboard(command + '\n').then(() => {
this.showNotification('Commande copiée avec Enter - Collez pour exécuter', 'success');
// Best-effort log
this.logTerminalCommand(command);
this.closeTerminalHistoryPanel();
const iframe = document.getElementById('terminalIframe');
if (iframe && iframe.contentWindow) {
iframe.focus();
iframe.contentWindow.focus();
}
}).catch(() => {
this.showNotification('Commande: ' + command, 'info');
});
}
copyTerminalCommand(index) {
const cmd = this.terminalCommandHistory[index];
if (!cmd) return;
const command = cmd.command || '';
this.copyTextToClipboard(command).then(() => {
this.showNotification('Commande copiée', 'success');
// Best-effort log
this.logTerminalCommand(command);
}).catch(() => {
this.showNotification('Impossible de copier', 'error');
});
}
formatRelativeTime(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'À l\'instant';
if (diffMin < 60) return `Il y a ${diffMin}min`;
if (diffHour < 24) return `Il y a ${diffHour}h`;
if (diffDay < 7) return `Il y a ${diffDay}j`;
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
}
}
// Initialize dashboard when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
console.log('Creating DashboardManager...');
window.dashboard = new DashboardManager();
console.log('DashboardManager created. Methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(dashboard)).filter(m => m.includes('Schedule')));
// Allow embedded terminal connect page to request closing the terminal drawer.
window.addEventListener('message', (event) => {
const data = event?.data;
if (!data || typeof data !== 'object') return;
if (data.type === 'terminal:closeDrawer') {
if (window.dashboard && typeof window.dashboard.closeTerminalDrawer === 'function') {
window.dashboard.closeTerminalDrawer();
}
}
if (data.type === 'terminal:reconnect') {
if (window.dashboard && typeof window.dashboard.reconnectTerminal === 'function') {
window.dashboard.reconnectTerminal();
}
}
});
});
// Global functions for onclick handlers
function showQuickActions() {
dashboard.showQuickActions();
}
function executeTask(taskType) {
dashboard.executeTask(taskType);
}
function addHost() {
dashboard.addHost();
}
function refreshTasks() {
dashboard.refreshTasks();
}
function clearLogs() {
dashboard.clearLogs();
}
function exportLogs() {
dashboard.exportLogs();
}
function closeModal() {
dashboard.closeModal();
}
window.showCreateScheduleModal = function(prefilledPlaybook = null) {
if (!window.dashboard) {
return;
}
if (typeof dashboard.showCreateScheduleModal === 'function') {
dashboard.showCreateScheduleModal(prefilledPlaybook);
return;
}
try {
dashboard.editingScheduleId = null;
dashboard.scheduleModalStep = 1;
if (typeof dashboard.getScheduleModalContent === 'function' &&
typeof dashboard.showModal === 'function') {
const content = dashboard.getScheduleModalContent(null, prefilledPlaybook || null);
dashboard.showModal('Nouveau Schedule', content, 'schedule-modal');
}
} catch (e) {
console.error('showCreateScheduleModal fallback error:', e);
}
};
// Export for testing (ESM/CommonJS compatible)
if (typeof module !== 'undefined' && module.exports) {
module.exports = { DashboardManager };
}