// tenant.js — page détail tenant : KPIs, graphique journalier (7j/30j), // répartition par service (30 derniers jours). const SERIES_COLORS = [ "--series-1", "--series-2", "--series-3", "--series-4", "--series-5", "--series-6", "--series-7", "--series-8", ]; const MAX_SERVICE_SLOTS = 8; const pathParts = window.location.pathname.split("/").filter(Boolean); // ["tenant", provider, name] const PROVIDER = decodeURIComponent(pathParts[1] || ""); const NAME = decodeURIComponent(pathParts[2] || ""); let currentRange = 30; let currentBuckets = []; let currentMode = "daily"; // "daily" | "monthly" let defaultServices = {}; const selectedBuckets = new Set(); let dailyChart = null; let servicesChart = null; async function loadDetail(range) { const res = await fetch(`/api/tenant/${PROVIDER}/${encodeURIComponent(NAME)}?range=${range}`); if (!res.ok) throw new Error(`API tenant: HTTP ${res.status}`); return res.json(); } function renderHeader(data) { document.title = `${NAME} — simple-cost-dashboard`; document.getElementById("tenant-title").innerHTML = `${providerLabel(data.tenant.provider)} ${escapeHtml(data.tenant.name)}`; } function renderStatusBanner(data) { const el = document.getElementById("status-banner"); const status = data.status; const isMonthly = data.period.mode === "monthly"; const hasAnyData = isMonthly ? data.has_monthly_data : data.has_data; if (status && status.status === "error") { el.innerHTML = ` `; } else if (!hasAnyData) { const msg = isMonthly ? "Aucune donnée mensuelle pour ce tenant — lance backfill_historical.py ou import_historical.py pour peupler l'historique." : "Pas encore de données journalières pour ce tenant — la collecte quotidienne n'a pas encore tourné, ou seules des données historiques mensuelles ont été importées."; el.innerHTML = ` `; } else { el.innerHTML = ""; } } function renderKpis(data) { const p = data.period; const isMonthly = p.mode === "monthly"; const kpiRow = document.getElementById("kpi-row"); kpiRow.classList.toggle("no-comparison", isMonthly); if (isMonthly) { document.getElementById("kpi-current-label").textContent = `Total — ${p.months.length} derniers mois`; } else { document.getElementById("kpi-current-label").textContent = `Total — ${p.range_days} derniers jours`; } document.getElementById("kpi-current").textContent = formatEUR(p.current_total); if (!isMonthly) { document.getElementById("kpi-previous-label").textContent = `Total — ${p.range_days} jours précédents`; document.getElementById("kpi-previous").textContent = p.has_previous ? formatEUR(p.previous_total) : "—"; const deltaEl = document.getElementById("kpi-delta"); const delta = formatPct(p.delta_pct); deltaEl.textContent = delta || "—"; deltaEl.className = `kpi-value kpi-delta ${deltaClass(p.delta_pct)}`; } document.getElementById("kpi-ytd").textContent = formatEUR(data.ytd_total); document.getElementById("kpi-ytd-label").textContent = `Total depuis le 1er janvier ${new Date().getFullYear()}`; } function dimmedColor(hex) { return Chart.helpers.color(hex).alpha(0.3).rgbString(); } function renderDailyChart(data) { const p = data.period; const isMonthly = p.mode === "monthly"; currentMode = isMonthly ? "monthly" : "daily"; currentBuckets = isMonthly ? p.months : p.days; const labelFn = isMonthly ? monthLabel : dayLabel; document.getElementById("daily-chart-title").textContent = isMonthly ? `Coûts mensuels — ${p.months.length} derniers mois` : `Coûts journaliers — ${p.range_days} derniers jours`; if (dailyChart) { dailyChart.destroy(); dailyChart = null; } const wrap = document.querySelector("#daily-chart").parentElement; if (!currentBuckets.length) { wrap.innerHTML = `
Aucune donnée sur cette période.
`; return; } if (!wrap.querySelector("canvas")) { wrap.innerHTML = ``; } const ctx = document.getElementById("daily-chart").getContext("2d"); const accent = cssVar("--series-1"); const surface = cssVar("--surface-1"); dailyChart = new Chart(ctx, { type: "bar", data: { labels: currentBuckets.map(labelFn), datasets: [ { label: "Coût", data: p.amounts, backgroundColor: (ctx2) => { if (selectedBuckets.size === 0) return accent; return selectedBuckets.has(currentBuckets[ctx2.dataIndex]) ? accent : dimmedColor(accent); }, borderColor: surface, borderWidth: { top: 2, right: 0, bottom: 0, left: 0 }, borderSkipped: false, borderRadius: { topLeft: 4, topRight: 4 }, maxBarThickness: isMonthly ? 32 : 18, }, ], }, options: { ...chartBaseOptions(), onHover: (evt, elements) => { evt.native.target.style.cursor = elements.length ? "pointer" : "default"; }, onClick: (evt, elements) => { if (!elements.length) return; const bucket = currentBuckets[elements[0].index]; if (selectedBuckets.has(bucket)) { selectedBuckets.delete(bucket); } else { selectedBuckets.add(bucket); } // Reporté au tick suivant : détruire ce chart depuis son propre // handler onClick corromprait les listeners internes de Chart.js. setTimeout(() => { renderDailyChart(data); refreshServicesSection(); }, 0); }, plugins: { ...chartBaseOptions().plugins, tooltip: { ...chartBaseOptions().plugins.tooltip, callbacks: { label: (item) => `Coût : ${formatEUR(item.parsed.y)}`, }, }, }, }, }); } function bucketServices(services) { const entries = Object.entries(services); if (entries.length <= MAX_SERVICE_SLOTS) { return { top: entries, otherTotal: 0 }; } const top = entries.slice(0, MAX_SERVICE_SLOTS - 1); const rest = entries.slice(MAX_SERVICE_SLOTS - 1); const otherTotal = rest.reduce((sum, [, amount]) => sum + amount, 0); return { top, otherTotal }; } function renderServicesChart(services) { const wrap = document.querySelector("#services-chart").parentElement; const entries = Object.entries(services); if (entries.length === 0) { wrap.innerHTML = `
Aucun coût par service sur cette période.
`; return; } const { top, otherTotal } = bucketServices(services); const labels = top.map(([name]) => name).concat(otherTotal > 0 ? ["Autres"] : []); const values = top.map(([, amount]) => amount).concat(otherTotal > 0 ? [otherTotal] : []); const colors = top.map((_, i) => cssVar(SERIES_COLORS[i])).concat(otherTotal > 0 ? [cssVar("--series-other")] : []); if (servicesChart) { servicesChart.destroy(); servicesChart = null; } if (!wrap.querySelector("canvas")) { wrap.innerHTML = ``; } const ctx = document.getElementById("services-chart").getContext("2d"); servicesChart = new Chart(ctx, { type: "bar", data: { labels, datasets: [ { data: values, backgroundColor: colors, maxBarThickness: 20, borderRadius: 4, borderSkipped: false, }, ], }, options: { indexAxis: "y", responsive: true, maintainAspectRatio: false, interaction: { mode: "nearest", intersect: true }, onHover: (evt, elements) => { evt.native.target.style.cursor = elements.length && labels[elements[0].index] !== "Autres" ? "pointer" : "default"; }, onClick: (evt, elements) => { if (!elements.length) return; const label = labels[elements[0].index]; if (label === "Autres") return; openResourceDrawer(label); }, plugins: { legend: { display: false }, tooltip: { backgroundColor: cssVar("--surface-1"), titleColor: cssVar("--text-primary"), bodyColor: cssVar("--text-secondary"), borderColor: cssVar("--border"), borderWidth: 1, padding: 10, callbacks: { label: (item) => formatEUR(item.parsed.x) }, }, }, scales: { x: { beginAtZero: true, grid: { color: cssVar("--gridline") }, border: { display: false }, ticks: { color: cssVar("--text-muted"), font: { size: 11 } }, }, y: { grid: { display: false }, border: { color: cssVar("--axis") }, ticks: { color: cssVar("--text-muted"), font: { size: 11 } }, }, }, }, }); } function renderServicesTable(services) { const wrap = document.getElementById("services-table-wrap"); const entries = Object.entries(services); document.getElementById("services-chart-sub").textContent = entries.length > 0 ? `${entries.length} service${entries.length > 1 ? "s" : ""} identifié${entries.length > 1 ? "s" : ""}` : ""; if (entries.length === 0) { wrap.innerHTML = `
Rien à afficher.
`; return; } const total = entries.reduce((sum, [, amount]) => sum + amount, 0) || 1; const rows = entries .map(([name, amount]) => { const pct = (amount / total) * 100; return ` ${escapeHtml(name)} ${formatEUR(amount)} ${pct.toFixed(1)} % `; }) .join(""); wrap.innerHTML = ` ${rows}
ServiceMontant%
`; wrap.querySelectorAll("tr.clickable").forEach((tr) => { tr.addEventListener("click", () => openResourceDrawer(tr.dataset.service)); }); } // --- Sélection de barres (jours/mois) -> filtre la répartition service -- function selectedBucketsInOrder() { return currentBuckets.filter((b) => selectedBuckets.has(b)); } function renderServicesSelectionNote() { const el = document.getElementById("services-selection-note"); const titleEl = document.getElementById("services-section-title"); if (selectedBuckets.size === 0) { el.innerHTML = ""; titleEl.textContent = "Répartition par service (30 derniers jours)"; return; } const labelFn = currentMode === "monthly" ? monthLabel : dayLabel; const ordered = selectedBucketsInOrder(); const noun = currentMode === "monthly" ? "mois" : "jour"; titleEl.textContent = `Répartition par service — ${ordered.length} ${noun}${ordered.length > 1 ? "s" : ""} sélectionné${ordered.length > 1 ? "s" : ""}`; el.innerHTML = ` Sélection : ${ordered.map(labelFn).join(", ")} `; document.getElementById("clear-services-selection").addEventListener("click", () => { selectedBuckets.clear(); if (dailyChart) { dailyChart.update(); } refreshServicesSection(); }); } async function refreshServicesSection() { renderServicesSelectionNote(); if (selectedBuckets.size === 0) { renderServicesChart(defaultServices); renderServicesTable(defaultServices); return; } const ordered = selectedBucketsInOrder(); const param = currentMode === "monthly" ? `months=${ordered.map(encodeURIComponent).join(",")}` : `days=${ordered.map(encodeURIComponent).join(",")}`; try { const res = await fetch(`/api/tenant/${PROVIDER}/${encodeURIComponent(NAME)}/services?${param}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); renderServicesChart(data.services); renderServicesTable(data.services); } catch (e) { document.getElementById("services-table-wrap").innerHTML = ``; } } // --- Drawer : détail par ressource d'un service ------------------------ function openResourceDrawer(serviceName) { document.getElementById("drawer-title").textContent = serviceName; document.getElementById("drawer-sub").textContent = "Chargement…"; document.getElementById("drawer-body").innerHTML = `
Chargement…
`; document.getElementById("resource-drawer").classList.add("open"); document.getElementById("resource-drawer").setAttribute("aria-hidden", "false"); document.getElementById("drawer-backdrop").classList.add("open"); fetch(`/api/tenant/${PROVIDER}/${encodeURIComponent(NAME)}/resources/${encodeURIComponent(serviceName)}`) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((data) => renderDrawerContent(serviceName, data)) .catch((e) => { document.getElementById("drawer-sub").textContent = ""; document.getElementById("drawer-body").innerHTML = ``; }); } function renderDrawerContent(serviceName, data) { const subEl = document.getElementById("drawer-sub"); const bodyEl = document.getElementById("drawer-body"); if (data.period_start && data.period_end) { subEl.textContent = `${dayLabel(data.period_start)} → ${dayLabel(data.period_end)}`; } else { subEl.textContent = ""; } if (data.status === "never_collected") { bodyEl.innerHTML = `
Le détail par ressource n'a pas encore été collecté pour ce tenant — il arrive au prochain passage de la collecte quotidienne.
`; return; } if (data.status === "unavailable") { const hint = PROVIDER === "aws" ? " Vérifie que «Resource IDs» est activé dans les préférences Cost Explorer de ce compte AWS." : ""; bodyEl.innerHTML = ``; return; } if (data.status === "error") { bodyEl.innerHTML = ``; return; } if (!data.resources.length) { bodyEl.innerHTML = `
Aucune ressource identifiée pour ce service sur cette période.
`; return; } const total = data.resources.reduce((sum, r) => sum + r.amount, 0) || 1; bodyEl.innerHTML = data.resources .map((r) => { const pct = (r.amount / total) * 100; return `
${escapeHtml(r.resource_name)}
${pct.toFixed(1)} %
${formatEUR(r.amount)}
`; }) .join(""); } function closeResourceDrawer() { document.getElementById("resource-drawer").classList.remove("open"); document.getElementById("resource-drawer").setAttribute("aria-hidden", "true"); document.getElementById("drawer-backdrop").classList.remove("open"); } function setupDrawer() { document.getElementById("drawer-close").addEventListener("click", closeResourceDrawer); document.getElementById("drawer-backdrop").addEventListener("click", closeResourceDrawer); document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeResourceDrawer(); }); } function setupRangeToggle() { const buttons = document.querySelectorAll("#range-toggle button"); buttons.forEach((btn) => { btn.classList.toggle("active", Number(btn.dataset.range) === currentRange); btn.addEventListener("click", async () => { currentRange = Number(btn.dataset.range); buttons.forEach((b) => b.classList.toggle("active", b === btn)); selectedBuckets.clear(); const data = await loadDetail(currentRange); defaultServices = data.services; renderKpis(data); renderDailyChart(data); refreshServicesSection(); }); }); } async function init() { if (!PROVIDER || !NAME) { document.getElementById("status-banner").innerHTML = ``; return; } try { const data = await loadDetail(currentRange); defaultServices = data.services; renderHeader(data); renderStatusBanner(data); renderKpis(data); renderDailyChart(data); refreshServicesSection(); setupRangeToggle(); setupDrawer(); } catch (e) { document.getElementById("status-banner").innerHTML = ``; } } init();