498 lines
17 KiB
JavaScript
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();
|