Files
simple-cost-dashboard/frontend/static/tenant.js
T
2026-08-07 17:19:49 +02:00

498 lines
17 KiB
JavaScript

// 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 =
`<span class="provider-badge ${data.tenant.provider}">${providerLabel(data.tenant.provider)}</span> ${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 = `
<div class="banner error">
<div>
<strong>Dernière collecte en échec</strong> (${escapeHtml(status.period_start || "")}) :
${escapeHtml(status.error_message || "erreur inconnue.")}
</div>
</div>`;
} else if (!hasAnyData) {
const msg = isMonthly
? "Aucune donnée mensuelle pour ce tenant — lance <code>backfill_historical.py</code> ou <code>import_historical.py</code> 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 = `
<div class="banner error">
<div><strong>Pas encore de données</strong> — ${msg}</div>
</div>`;
} 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 = `<div class="empty-state">Aucune donnée sur cette période.</div>`;
return;
}
if (!wrap.querySelector("canvas")) {
wrap.innerHTML = `<canvas id="daily-chart"></canvas>`;
}
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 = `<div class="empty-state">Aucun coût par service sur cette période.</div>`;
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 = `<canvas id="services-chart"></canvas>`;
}
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 = `<div class="empty-state">Rien à afficher.</div>`;
return;
}
const total = entries.reduce((sum, [, amount]) => sum + amount, 0) || 1;
const rows = entries
.map(([name, amount]) => {
const pct = (amount / total) * 100;
return `
<tr class="clickable" data-service="${escapeHtml(name)}" title="Voir le détail par ressource">
<td>${escapeHtml(name)}</td>
<td class="num">${formatEUR(amount)}</td>
<td class="num">${pct.toFixed(1)} %</td>
</tr>`;
})
.join("");
wrap.innerHTML = `
<table class="data-table">
<thead>
<tr><th>Service</th><th class="num">Montant</th><th class="num">%</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
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 = `
<span>Sélection : ${ordered.map(labelFn).join(", ")}</span>
<button type="button" id="clear-services-selection">Réinitialiser</button>`;
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 =
`<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
}
}
// --- 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 = `<div class="skeleton">Chargement…</div>`;
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 =
`<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
});
}
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 = `<div class="empty-state">
Le détail par ressource n'a pas encore été collecté pour ce tenant —
il arrive au prochain passage de la collecte quotidienne.
</div>`;
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 = `<div class="banner error">
<div><strong>Détail par ressource indisponible</strong> pour ce service.${escapeHtml(hint)}<br>
${escapeHtml(data.message || "")}</div>
</div>`;
return;
}
if (data.status === "error") {
bodyEl.innerHTML = `<div class="banner error">
<div><strong>Échec de la dernière collecte du détail ressources</strong> :
${escapeHtml(data.message || "erreur inconnue")}</div>
</div>`;
return;
}
if (!data.resources.length) {
bodyEl.innerHTML = `<div class="empty-state">Aucune ressource identifiée pour ce service sur cette période.</div>`;
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 `
<div class="resource-row" title="${escapeHtml(r.resource_id)}">
<div>
<div class="resource-name">${escapeHtml(r.resource_name)}</div>
<div class="resource-pct">${pct.toFixed(1)} %</div>
</div>
<div class="resource-amount">${formatEUR(r.amount)}</div>
</div>`;
})
.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 =
`<div class="banner error"><strong>URL invalide.</strong></div>`;
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 =
`<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
}
}
init();