`,
);
}
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 = Array.isArray(this.adhocWidgetLogs) ? this.adhocWidgetLogs.length : 0;
const success = (this.adhocWidgetLogs || []).filter((l) => (l.status || "").toLowerCase() === "completed").length;
const failed = (this.adhocWidgetLogs || []).filter((l) => (l.status || "").toLowerCase() === "failed").length;
// Mettre à jour les stats
if (successEl) successEl.textContent = success;
if (failedEl) failedEl.textContent = failed;
if (totalEl) totalEl.textContent = total;
if (countEl) countEl.textContent = this.adhocWidgetTotalCount > 0 ? `${this.adhocWidgetTotalCount} exécution${this.adhocWidgetTotalCount > 1 ? "s" : ""}` : "";
// Afficher les dernières exécutions
const displayedLimit = this.adhocWidgetLimit + this.adhocWidgetOffset;
const displayedLogs = (this.adhocWidgetLogs || []).slice(0, displayedLimit);
if (displayedLogs.length === 0) {
historyContainer.innerHTML = `
Aucune exécution
Ouvrez la console pour exécuter des commandes
`;
if (loadMoreBtn) loadMoreBtn.classList.add("hidden");
return;
}
historyContainer.innerHTML = displayedLogs
.map((log) => {
const status = (log.status || "").toLowerCase();
const isSuccess = status === "completed";
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 = log.created_at ? new Date(log.created_at) : new Date();
const timeAgo = this.formatTimeAgo(date);
// Extraire le nom de la commande (première partie avant |)
const taskName = (log.task_name || "").trim();
const cmdName = taskName ? taskName.replace(/^ad-?hoc\s*:\s*/i, "").split(" ")[0] : "Ad-hoc";
// Trouver la catégorie
const catColor = "#60a5fa";
return `
${this.escapeHtml(cmdName)}
Ad-hoc
${this.escapeHtml(log.target || "all")}
${timeAgo}
${log.duration_seconds ? ` ${Number(log.duration_seconds).toFixed(1)}s ` : ""}
`;
})
.join("");
// Afficher/masquer le bouton "Charger plus"
if (loadMoreBtn) {
const moreToDisplayLocally = (this.adhocWidgetLogs || []).length > displayedLogs.length;
const moreOnServer = Boolean(this.adhocWidgetHasMore);
if (moreToDisplayLocally || moreOnServer) {
loadMoreBtn.classList.remove("hidden");
const remaining = Math.max(0, this.adhocWidgetTotalCount - displayedLogs.length);
loadMoreBtn.innerHTML = `
Charger plus (${remaining} restantes)`;
} else {
loadMoreBtn.classList.add("hidden");
}
}
}
renderFavoriteContainersWidget() {
const container = document.getElementById("dashboard-favorites");
const countEl = document.getElementById("dashboard-favorites-count");
const searchInput = document.getElementById("dashboard-favorites-search");
const clearBtn = document.getElementById("dashboard-favorites-search-clear");
if (!container) return;
const fm = window.favoritesManager;
if (!fm) {
container.innerHTML = `
`;
return;
}
let favorites = Array.from(fm.favoritesById.values());
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : "";
if (searchTerm) {
favorites = favorites.filter((f) => {
const dc = f.docker_container;
if (!dc) return false;
return (dc.name || "").toLowerCase().includes(searchTerm) || (dc.host_name || "").toLowerCase().includes(searchTerm) || (dc.image || "").toLowerCase().includes(searchTerm);
});
}
if (clearBtn) {
clearBtn.classList.toggle("hidden", !searchTerm);
}
if (countEl) {
countEl.textContent = favorites.length > 0 ? `${favorites.length} favori${favorites.length > 1 ? "s" : ""}` : "";
}
if (favorites.length === 0) {
container.innerHTML = `
Aucun container favori
Ajoute des favoris depuis la page Containers
`;
return;
}
const groups = Array.isArray(fm.groups) ? fm.groups.slice() : [];
groups.sort((a, b) => {
const ao = Number(a.sort_order || 0);
const bo = Number(b.sort_order || 0);
if (ao !== bo) return ao - bo;
return String(a.name || "").localeCompare(String(b.name || ""), "fr");
});
const groupsById = new Map(groups.map((g) => [g.id, g]));
const grouped = new Map();
const uncategorizedKey = "uncategorized";
favorites.forEach((f) => {
const gid = f.group_id;
const key = gid ? String(gid) : uncategorizedKey;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(f);
});
const orderedGroupKeys = [];
groups.forEach((g) => {
const key = String(g.id);
if (grouped.has(key)) orderedGroupKeys.push(key);
});
if (grouped.has(uncategorizedKey)) orderedGroupKeys.push(uncategorizedKey);
const stateColor = (state) => {
const s = String(state || "").toLowerCase();
if (s === "running") return "green";
if (s === "paused") return "yellow";
if (s === "restarting") return "orange";
if (s === "created") return "blue";
if (s === "exited" || s === "dead") return "red";
return "gray";
};
const portLinks = (ports, hostIp) => {
if (!ports || !hostIp) return "";
const portStr = ports.raw || (typeof ports === "string" ? ports : "");
if (!portStr) return "";
const portRegex = /(?:([\d.]+):)?(\d+)->\d+\/tcp/g;
const links = [];
const seen = new Set();
let match;
while ((match = portRegex.exec(portStr)) !== null) {
const bindIp = match[1] || "0.0.0.0";
const hostPort = match[2];
if (bindIp === "127.0.0.1" || bindIp === "::1") continue;
if (seen.has(hostPort)) continue;
seen.add(hostPort);
const protocol = ["443", "8443", "9443"].includes(hostPort) ? "https" : "http";
const url = `${protocol}://${hostIp}:${hostPort}`;
links.push(`
${hostPort}
`);
}
return links.slice(0, 4).join("");
};
const groupTitle = (key) => {
if (key === uncategorizedKey) return { name: "Non classé", color: null, icon_key: null };
const g = groupsById.get(Number(key));
return { name: g?.name || "Groupe", color: g?.color || null, icon_key: g?.icon_key || null };
};
const groupHtml = orderedGroupKeys
.map((key) => {
const title = groupTitle(key);
const items = grouped.get(key) || [];
const iconColor = title.color || "#7c3aed";
const iconKey = title.icon_key || "lucide:star";
const headerPill = `
`;
return `
${headerPill}
${this.escapeHtml(title.name)}
${items.length}
${items
.map((f) => {
const dc = f.docker_container;
if (!dc) return "";
const hostStatus = (dc.host_docker_status || "").toLowerCase();
const hostOffline = hostStatus && hostStatus !== "online";
const dotColor = stateColor(dc.state);
const isRunning = String(dc.state || "").toLowerCase() === "running";
const favId = f.id;
const disabledClass = hostOffline ? "opacity-30 grayscale" : "";
const btnDisabled = hostOffline ? "disabled" : "";
const custom = window.containerCustomizationsManager?.get(dc.host_id, dc.container_id);
const iconKey = custom?.icon_key || "";
const iconColor = custom?.icon_color || "#9ca3af";
const bgColor = custom?.bg_color || "";
const bgStyle = bgColor ? `background:${this.escapeHtml(bgColor)};` : "";
const iconHtml = iconKey
? `
`
: "";
return `
${iconHtml}
${this.escapeHtml(dc.name)}
${this.escapeHtml(dc.host_name)}
${this.escapeHtml(dc.status || dc.state || "")}
${portLinks(dc.ports, dc.host_ip)}
`;
})
.join("")}
`;
})
.join("");
container.innerHTML = groupHtml;
}
async favoriteContainerAction(hostId, containerId, action) {
try {
this.showNotification(`${action}...`, "info");
const result = await this.apiCall(`/api/docker/containers/${encodeURIComponent(hostId)}/${encodeURIComponent(containerId)}/${encodeURIComponent(action)}`, {
method: "POST",
});
if (result && result.success) {
this.showNotification(`Action ${action} OK`, "success");
} else {
this.showNotification(`Erreur: ${result?.error || result?.message || "Échec"}`, "error");
}
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, "error");
} finally {
if (window.favoritesManager) {
window.favoritesManager.load().catch(() => null);
}
}
}
async removeFavoriteContainer(favoriteId) {
if (!window.favoritesManager) return;
try {
await window.favoritesManager.ensureInit();
await window.favoritesManager.removeFavoriteById(Number(favoriteId));
this.showNotification("Favori retiré", "success");
} catch (e) {
this.showNotification(`Erreur favoris: ${e.message}`, "error");
}
}
async showMoveFavoriteModal(hostId, containerId) {
const fm = window.favoritesManager;
if (!fm) return;
try {
await fm.ensureInit();
await fm.listGroups();
const groups = fm.groups || [];
const options = [`
Non classé `, ...groups.map((g) => `
${this.escapeHtml(g.name)} `)].join("");
this.showModal(
"Déplacer le favori",
`
`,
);
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, "error");
}
}
async moveFavoriteToGroup(event, hostId, containerId) {
event.preventDefault();
const fm = window.favoritesManager;
if (!fm) return;
const select = document.getElementById("favorite-move-group");
const raw = select ? select.value : "";
const groupId = raw ? Number(raw) : null;
try {
await fm.ensureInit();
await fm.moveFavoriteToGroup(hostId, containerId, groupId);
this.closeModal();
this.showNotification("Favori déplacé", "success");
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, "error");
}
}
async showFavoriteGroupsModal() {
const fm = window.favoritesManager;
if (!fm) return;
try {
await fm.ensureInit();
await fm.listGroups();
const groups = (fm.groups || []).slice().sort((a, b) => {
const ao = Number(a.sort_order || 0);
const bo = Number(b.sort_order || 0);
if (ao !== bo) return ao - bo;
return String(a.name || "").localeCompare(String(b.name || ""), "fr");
});
const rows =
groups.length === 0
? `
Aucun groupe
`
: groups
.map(
(g) => `
${this.escapeHtml(g.name)}
Ordre: ${Number(g.sort_order || 0)}
`,
)
.join("");
this.showModal(
"Groupes de favoris",
`
${groups.length} groupe(s)
Ajouter
${rows}
Fermer
`,
);
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, "error");
}
}
showAddFavoriteGroupModal() {
const fm = window.favoritesManager;
if (!fm) return;
this.showModal(
"Ajouter un groupe de favoris",
`
`,
);
}
async createFavoriteGroup(event) {
event.preventDefault();
const fm = window.favoritesManager;
if (!fm) return;
const name = document.getElementById("fav-group-name")?.value?.trim() || "";
const sortOrder = Number(document.getElementById("fav-group-order")?.value || 0);
const color = document.getElementById("fav-group-color-text")?.value?.trim() || null;
const iconKey = document.getElementById("fav-group-icon-key")?.value?.trim() || null;
try {
await fm.createGroup({ name, sort_order: sortOrder, color, icon_key: iconKey });
this.closeModal();
this.showNotification("Groupe créé", "success");
this.renderFavoriteContainersWidget();
this.showFavoriteGroupsModal();
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, "error");
}
}
async showEditFavoriteGroupModal(groupId) {
const fm = window.favoritesManager;
if (!fm) return;
await fm.ensureInit();
await fm.listGroups();
const g = (fm.groups || []).find((x) => Number(x.id) === Number(groupId));
if (!g) {
this.showNotification("Groupe introuvable", "error");
return;
}
this.showModal(
"Modifier le groupe",
`
`,
);
}
async updateFavoriteGroup(event, groupId) {
event.preventDefault();
const fm = window.favoritesManager;
if (!fm) return;
const name = document.getElementById("fav-group-name")?.value?.trim() || "";
const sortOrder = Number(document.getElementById("fav-group-order")?.value || 0);
const color = document.getElementById("fav-group-color-text")?.value?.trim() || null;
const iconKey = document.getElementById("fav-group-icon-key")?.value?.trim() || null;
try {
await fm.updateGroup(Number(groupId), { name, sort_order: sortOrder, color, icon_key: iconKey });
this.closeModal();
this.showNotification("Groupe mis à jour", "success");
this.renderFavoriteContainersWidget();
this.showFavoriteGroupsModal();
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, "error");
}
}
async confirmDeleteFavoriteGroup(groupId) {
const fm = window.favoritesManager;
if (!fm) return;
await fm.ensureInit();
await fm.listGroups();
const g = (fm.groups || []).find((x) => Number(x.id) === Number(groupId));
if (!g) {
this.showNotification("Groupe introuvable", "error");
return;
}
this.showModal(
"Confirmer la suppression",
`
Supprimer le groupe
${this.escapeHtml(g.name)}
Les favoris seront déplacés en "Non classé".
Supprimer
Annuler
`,
);
}
async deleteFavoriteGroup(groupId) {
const fm = window.favoritesManager;
if (!fm) return;
try {
await fm.deleteGroup(Number(groupId));
this.closeModal();
this.showNotification("Groupe supprimé", "success");
this.renderFavoriteContainersWidget();
this.showFavoriteGroupsModal();
} catch (e) {
this.showNotification(`Erreur: ${e.message}`, "error");
}
}
/**
* Expand all favorite groups
*/
expandAllFavorites() {
const details = document.querySelectorAll("#dashboard-favorites details");
details.forEach((detail) => (detail.open = true));
}
/**
* Collapse all favorite groups
*/
collapseAllFavorites() {
const details = document.querySelectorAll("#dashboard-favorites details");
details.forEach((detail) => (detail.open = false));
}
/**
* Clear favorites search
*/
clearFavoritesSearch() {
const searchInput = document.getElementById("dashboard-favorites-search");
if (searchInput) {
searchInput.value = "";
this.renderFavoriteContainersWidget();
}
}
/**
* Open container drawer from favorites widget
*/
async openContainerDrawerFromFavorites(hostId, containerId) {
const wantedHostId = String(hostId);
const wantedContainerId = String(containerId);
const tryOpen = async () => {
if (!window.containersPage || typeof window.containersPage.openDrawer !== "function") return false;
if (typeof window.containersPage.ensureInit === "function") {
await window.containersPage.ensureInit();
}
await window.containersPage.openDrawer(wantedHostId, wantedContainerId);
return true;
};
try {
if (await tryOpen()) return;
this.showNotification("Chargement du panneau...", "info");
if (typeof navigateTo === "function") {
navigateTo("docker-containers");
}
const maxAttempts = 20;
for (let i = 0; i < maxAttempts; i++) {
await new Promise((resolve) => setTimeout(resolve, 150));
if (await tryOpen()) return;
}
} catch (e) {
this.showNotification(`Erreur drawer: ${e.message}`, "error");
}
}
/**
* Charger plus d'historique dans le widget
*/
async loadMoreAdhocHistory() {
try {
// D'abord augmenter le nombre d'éléments affichés
this.adhocWidgetOffset += 5;
// Si on a déjà assez de logs chargés pour l'affichage, pas besoin de re-fetch.
const desiredCount = this.adhocWidgetLimit + this.adhocWidgetOffset;
const loadedCount = (this.adhocWidgetLogs || []).length;
if (loadedCount < desiredCount && this.adhocWidgetHasMore) {
const nextOffset = loadedCount;
const nextData = await this.apiCall(`/api/tasks/logs?source_type=adhoc&limit=200&offset=${nextOffset}`);
const nextLogs = nextData.logs || [];
this.adhocWidgetLogs = [...(this.adhocWidgetLogs || []), ...nextLogs];
this.adhocWidgetTotalCount = Number(nextData.total_count || this.adhocWidgetTotalCount || this.adhocWidgetLogs.length || 0);
this.adhocWidgetHasMore = Boolean(nextData.has_more);
}
this.renderAdhocWidget();
} catch (error) {
console.error("Erreur chargement exécutions ad-hoc:", error);
this.showNotification("Erreur chargement historique Ad-Hoc", "error");
}
}
/**
* 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 = `
Zero Matches
Adjust filter parameters to broaden matrix scan
`;
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("");
const statusLabel = schedule.enabled
? '
ACTIVE '
: '
PAUSED ';
return `
${this.escapeHtml(schedule.name)}
${statusLabel}
${tagsHtml}
${this.escapeHtml(schedule.description || "No description provided")}
${this.escapeHtml(schedule.playbook)}
${this.escapeHtml(schedule.target)}
${recurrenceText}
NEXT: ${nextRunText}
${lastRunHtml ? `LAST: ${lastRunHtml} ` : ""}
${schedule.enabled
? `
`
: `
`
}
`;
}
updateSchedulesStats() {
const total = this.schedules.length;
const active = this.schedules.filter((s) => s.enabled).length;
const paused = total - active;
const failures = this.schedulesStats.failures_24h || 0;
const statsContainer = document.querySelector(".schedules-stats-summary");
if (statsContainer) {
statsContainer.innerHTML = `
Matrix Total
${total} TASKS
Active Link
${active} ENGAGED
Standby Mode
${paused} SUSPENDED
Fault Events
${failures} /24H
`;
}
// Keep legacy IDs for compatibility with other view updates
const activeCountEl = document.getElementById("schedules-active-count");
if (activeCountEl) activeCountEl.textContent = active;
const pausedCountEl = document.getElementById("schedules-paused-count");
if (pausedCountEl) pausedCountEl.textContent = paused;
const failuresEl = document.getElementById("schedules-failures-24h");
if (failuresEl) failuresEl.textContent = failures;
// Dashboard widget
const dashboardActiveEl = document.getElementById("dashboard-schedules-active");
if (dashboardActiveEl) dashboardActiveEl.textContent = active;
const dashboardFailuresEl = document.getElementById("dashboard-schedules-failures");
if (dashboardFailuresEl) dashboardFailuresEl.textContent = failures;
// 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";
// IMPORTANT: do NOT use apiCall() here.
// apiCall() handles 401 by calling showNotification(), which would create an infinite loop
// when unauthenticated.
if (this.accessToken) {
fetch(`${this.apiBase}/api/alerts`, {
method: "POST",
headers: this.getAuthHeaders(),
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(force = false) {
// Limit check frequency unless forced
const now = Date.now();
if (!force && this.lastTerminalStatusCheck && now - this.lastTerminalStatusCheck < 10000) {
return;
}
this.lastTerminalStatusCheck = now;
try {
const data = await this.apiCall("/api/terminal/status");
if (data && typeof data.debug_mode === "boolean") {
const prev = Boolean(this.debugModeEnabled);
this.debugModeEnabled = Boolean(data.debug_mode);
this.setDebugBadgeVisible(this.isDebugEnabled());
if (prev !== Boolean(this.debugModeEnabled)) {
try {
this.renderHosts();
} catch (e) { }
}
}
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) {
// Prevent multiple popouts
if (this.terminalPopoutOpening) {
return;
}
this.terminalPopoutOpening = true;
try {
const status = await this.checkTerminalFeatureStatus();
if (!status.available) {
this.showNotification("Terminal SSH non disponible: ttyd n'est pas installé", "error");
return;
}
} catch (e) {
console.error("Failed to check terminal feature status:", e);
this.showNotification("Erreur terminal: impossible de vérifier la disponibilité", "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");
} finally {
this.terminalPopoutOpening = false;
}
}
showTerminalDrawer(hostName, hostIp, loading = false) {
this.terminalDrawerOpen = true;
this.terminalHistoryPanelOpen = false;
this.terminalHistoryPanelPinned = false;
// 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;
this.terminalHistoryPanelOpen = false;
this.terminalHistoryPanelPinned = 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);
if (e && (e.status === 404 || e.status === 403)) {
this.stopTerminalHeartbeat();
this.terminalSession = null;
return;
}
}
}, 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(true);
} 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(force = false) {
// If panel is pinned/docked, don't close on command execute unless forced
if (this.terminalHistoryPanelPinned && !force) return;
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;
const params = new URLSearchParams();
params.set("limit", "100");
if (query) params.set("query", query);
let endpoint;
if (allHosts) {
// Global history - pass session token for auth in the pop-out context
const token = this.terminalSession.token;
if (token) params.set("token", token);
endpoint = `/api/terminal/command-history?${params.toString()}`;
} else {
// Per-host unique commands (includes command_hash for pinning)
const token = this.terminalSession.token;
if (token) params.set("token", token);
endpoint = `/api/terminal/${hostId}/command-history/unique?${params.toString()}`;
}
const response = await this.apiCall(endpoint);
let commands = response.commands || [];
// Client-side time filtering
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 * 86400000);
break;
case "month":
cutoff = new Date(now.getTime() - 30 * 86400000);
break;
}
if (cutoff) {
commands = commands.filter((cmd) => {
const cmdDate = new Date(cmd.last_used || cmd.created_at);
return cmdDate >= cutoff;
});
}
}
// Client-side pinned-only filter
if (this.terminalHistoryPinnedOnly) {
commands = commands.filter((cmd) => cmd.is_pinned);
}
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 `
${cmd.is_pinned ? ' ' : ""}${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);
}
togglePinnedOnlyFilter() {
this.terminalHistoryPinnedOnly = !this.terminalHistoryPinnedOnly;
const btn = document.getElementById("terminalHistoryPinnedOnly");
if (btn) btn.classList.toggle("active", this.terminalHistoryPinnedOnly);
this.terminalHistorySelectedIndex = -1;
this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
}
toggleHistoryPanelPin() {
this.terminalHistoryPanelPinned = !this.terminalHistoryPanelPinned;
const btn = document.getElementById("terminalHistoryDockBtn");
if (btn) btn.classList.toggle("active", this.terminalHistoryPanelPinned);
// In pinned mode, reposition panel as docked above terminal body
const panel = document.getElementById("terminalHistoryPanel");
if (panel) panel.classList.toggle("docked", this.terminalHistoryPanelPinned);
}
async togglePinHistory(index) {
if (!this.terminalSession) return;
const cmd = this.terminalCommandHistory[index];
if (!cmd || !cmd.command_hash) return;
const newPinned = !cmd.is_pinned;
try {
const hId = cmd.host_id || this.terminalSession.host.id;
await this.apiCall(`/api/terminal/${hId}/command-history/${cmd.command_hash}/pin`, {
method: "POST",
body: JSON.stringify({ is_pinned: newPinned }),
});
cmd.is_pinned = newPinned;
this.renderTerminalHistory(this.terminalHistorySearchQuery);
} catch (e) {
this.showNotification("Impossible de modifier l'épingle", "error");
}
}
// Find the xterm instance inside the terminal iframe (or nested iframe)
_getTermFromIframe() {
// The side-terminal embeds the connect page in #terminalIframe
// Inside that page, the ttyd terminal runs in #terminalFrame
// We try both levels.
const iframe = document.getElementById("terminalIframe");
if (!iframe || !iframe.contentWindow) return null;
try {
// Level 1: the connect page itself (pop-out case)
const cw1 = iframe.contentWindow;
if (cw1.term && typeof cw1.term.paste === "function") return { term: cw1.term, win: cw1 };
// Level 2: nested #terminalFrame inside connect page (side-terminal case)
const inner = cw1.document && cw1.document.getElementById("terminalFrame");
if (inner && inner.contentWindow) {
const cw2 = inner.contentWindow;
if (cw2.term && typeof cw2.term.paste === "function") return { term: cw2.term, win: cw2 };
}
} catch (e) {
/* cross-origin */
}
return null;
}
selectAndInsertHistoryCommand(index) {
const cmd = this.terminalCommandHistory[index];
if (!cmd) return;
const command = cmd.command || "";
const found = this._getTermFromIframe();
if (found) {
found.term.focus();
found.term.paste(command);
this.closeTerminalHistoryPanel();
return;
}
// Fallback: postMessage to connect page
const iframe = document.getElementById("terminalIframe");
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage({ type: "terminal:paste", text: command }, "*");
return;
}
// Last resort: clipboard
this.copyTextToClipboard(command)
.then(() => {
this.showNotification("Commande copiée - Collez avec Ctrl+Shift+V", "success");
})
.catch(() => { });
}
executeHistoryCommand(index) {
const cmd = this.terminalCommandHistory[index];
if (!cmd) return;
const command = cmd.command || "";
const found = this._getTermFromIframe();
if (found) {
found.term.focus();
found.term.paste(command + "\r");
this.closeTerminalHistoryPanel();
return;
}
// Fallback: postMessage to connect page
const iframe = document.getElementById("terminalIframe");
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage({ type: "terminal:paste", text: command + "\r" }, "*");
this.closeTerminalHistoryPanel();
return;
}
// Last resort: clipboard + newline
this.copyTextToClipboard(command + "\n")
.then(() => {
this.showNotification("Commande copiée - Collez pour exécuter", "success");
this.closeTerminalHistoryPanel();
})
.catch(() => { });
}
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();
window.DashboardManager = DashboardManager;
if (window.dashboard && typeof window.dashboard.init === "function") {
window.dashboard.init();
}
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);
}
};
if (typeof window !== "undefined") {
window.DashboardManager = DashboardManager;
}
// Export for testing (ESM/CommonJS compatible)
if (typeof module !== "undefined" && module.exports) {
module.exports = { DashboardManager };
}