first (big) draft
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user