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

271 lines
8.8 KiB
JavaScript

// app.js — page globale : KPIs, graphique mensuel AWS/Azure (cliquable,
// multi-sélection), table des tenants.
let allTenants = [];
let currentMonths = [];
let currentProvidersTotals = { aws: [], azure: [] };
let monthlyChart = null;
const selectedMonths = new Set();
async function loadOverview() {
const res = await fetch("/api/overview?months=12");
if (!res.ok) throw new Error(`API overview: HTTP ${res.status}`);
return res.json();
}
function renderKpis(data) {
document.getElementById("kpi-total").textContent = formatEUR(data.kpis.total_current_month);
document.getElementById("kpi-aws").textContent = formatEUR(data.kpis.total_aws_current_month);
document.getElementById("kpi-azure").textContent = formatEUR(data.kpis.total_azure_current_month);
const errorCount = data.kpis.tenants_error.length;
const errEl = document.getElementById("kpi-errors");
errEl.textContent = String(errorCount);
errEl.style.color = errorCount > 0 ? "var(--status-critical)" : "";
const banner = document.getElementById("error-banner");
if (errorCount > 0) {
const names = data.kpis.tenants_error
.map((t) => `${providerLabel(t.provider)} / ${escapeHtml(t.name)}`)
.join(", ");
banner.innerHTML = `
<div class="banner error">
<div>
<strong>${errorCount} tenant${errorCount > 1 ? "s" : ""} en échec de collecte</strong> :
${names}. Voir la table ci-dessous ou <code>cron.log</code> pour le détail.
</div>
</div>`;
} else {
banner.innerHTML = "";
}
}
function dimmed(hex) {
return Chart.helpers.color(hex).alpha(0.3).rgbString();
}
function renderMonthlyChart() {
const labels = currentMonths.map(monthLabel);
const surface = cssVar("--surface-1");
const awsColor = cssVar("--provider-aws");
const azureColor = cssVar("--provider-azure");
const colorFor = (base) => (ctx) => {
if (selectedMonths.size === 0) return base;
return selectedMonths.has(currentMonths[ctx.dataIndex]) ? base : dimmed(base);
};
if (monthlyChart) {
monthlyChart.destroy();
monthlyChart = null;
}
const ctx = document.getElementById("monthly-chart").getContext("2d");
monthlyChart = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [
{
label: "AWS",
data: currentProvidersTotals.aws,
backgroundColor: colorFor(awsColor),
borderColor: surface,
borderWidth: { top: 2, right: 0, bottom: 0, left: 0 },
borderSkipped: false,
borderRadius: { topLeft: 4, topRight: 4 },
stack: "total",
maxBarThickness: 24,
},
{
label: "Azure",
data: currentProvidersTotals.azure,
backgroundColor: colorFor(azureColor),
borderColor: surface,
borderWidth: { top: 2, right: 0, bottom: 0, left: 0 },
borderSkipped: false,
stack: "total",
maxBarThickness: 24,
},
],
},
options: {
...chartBaseOptions(),
onHover: (evt, elements) => {
evt.native.target.style.cursor = elements.length ? "pointer" : "default";
},
onClick: (evt, elements) => {
if (!elements.length) return;
const monthKey = currentMonths[elements[0].index];
if (selectedMonths.has(monthKey)) {
selectedMonths.delete(monthKey);
} else {
selectedMonths.add(monthKey);
}
// Ne pas détruire ce chart depuis son propre handler onClick — Chart.js
// est encore en train de dispatcher l'événement. On reporte au tick
// suivant pour éviter de corrompre ses listeners internes.
setTimeout(() => {
renderMonthlyChart();
renderTenantsTable(filteredTenants());
}, 0);
},
plugins: {
...chartBaseOptions().plugins,
tooltip: {
...chartBaseOptions().plugins.tooltip,
callbacks: {
label: (item) => `${item.dataset.label} : ${formatEUR(item.parsed.y)}`,
},
},
},
scales: {
x: { ...chartBaseOptions().scales.x, stacked: true },
y: { ...chartBaseOptions().scales.y, stacked: true },
},
},
});
}
function statusInfo(tenant) {
if (tenant.status === "ok") return { cls: "ok", label: "OK" };
if (tenant.status === "error") return { cls: "error", label: "Erreur" };
return { cls: "unknown", label: "Pas encore collecté" };
}
function selectedMonthIndexes() {
return currentMonths
.map((mk, i) => (selectedMonths.has(mk) ? i : -1))
.filter((i) => i >= 0);
}
function renderSelectionNote() {
const el = document.getElementById("month-selection-note");
if (selectedMonths.size === 0) {
el.innerHTML = "";
return;
}
const labels = currentMonths.filter((mk) => selectedMonths.has(mk)).map(monthLabel).join(", ");
el.innerHTML = `
<span>Sélection : ${labels}</span>
<button type="button" id="clear-selection">Réinitialiser</button>`;
document.getElementById("clear-selection").addEventListener("click", () => {
selectedMonths.clear();
renderMonthlyChart();
renderSelectionNote();
renderTenantsTable(filteredTenants());
});
}
function renderTenantsTable(tenants) {
const wrap = document.getElementById("tenants-table-wrap");
renderSelectionNote();
if (tenants.length === 0) {
wrap.innerHTML = `<div class="empty-state">Aucun tenant collecté pour l'instant. Lance <code>collect.py</code> ou <code>import_historical.py</code> pour peupler le dashboard.</div>`;
return;
}
const indexes = selectedMonthIndexes();
const selectionMode = indexes.length > 0;
const rows = tenants
.map((t) => {
const st = statusInfo(t);
if (selectionMode) {
const amount = indexes.reduce((sum, i) => sum + (t.monthly[i] || 0), 0);
return `
<tr class="clickable" data-provider="${t.provider}" data-name="${escapeHtml(t.name)}">
<td>
<span class="name-cell">
<span class="provider-badge ${t.provider}">${providerLabel(t.provider)}</span>
${escapeHtml(t.name)}
</span>
</td>
<td class="num">${formatEUR(amount)}</td>
<td>
<span class="status-label">
<span class="status-dot ${st.cls}"></span>${st.label}
</span>
</td>
</tr>`;
}
const delta = formatPct(t.delta_pct);
return `
<tr class="clickable" data-provider="${t.provider}" data-name="${escapeHtml(t.name)}">
<td>
<span class="name-cell">
<span class="provider-badge ${t.provider}">${providerLabel(t.provider)}</span>
${escapeHtml(t.name)}
</span>
</td>
<td class="num">${formatEUR(t.current_month)}</td>
<td class="num">${formatEUR(t.previous_month)}</td>
<td class="num kpi-delta ${deltaClass(t.delta_pct)}" style="justify-content:flex-end; display:flex;">
${delta ? `${deltaArrow(t.delta_pct)} ${delta}` : "—"}
</td>
<td>
<span class="status-label">
<span class="status-dot ${st.cls}"></span>${st.label}
</span>
</td>
</tr>`;
})
.join("");
const header = selectionMode
? `<tr><th>Tenant</th><th class="num">Montant (sélection)</th><th>Statut collecte</th></tr>`
: `<tr>
<th>Tenant</th>
<th class="num">Mois en cours</th>
<th class="num">Mois précédent</th>
<th class="num">Évolution</th>
<th>Statut collecte</th>
</tr>`;
wrap.innerHTML = `
<table class="data-table">
<thead>${header}</thead>
<tbody>${rows}</tbody>
</table>`;
wrap.querySelectorAll("tr.clickable").forEach((tr) => {
tr.addEventListener("click", () => {
window.location.href = `/tenant/${tr.dataset.provider}/${encodeURIComponent(tr.dataset.name)}`;
});
});
}
function filteredTenants() {
const q = document.getElementById("tenant-search").value.trim().toLowerCase();
if (!q) return allTenants;
return allTenants.filter((t) => t.name.toLowerCase().includes(q));
}
function setupSearch() {
document.getElementById("tenant-search").addEventListener("input", () => {
renderTenantsTable(filteredTenants());
});
}
async function init() {
try {
const data = await loadOverview();
allTenants = data.tenants;
currentMonths = data.months;
currentProvidersTotals = data.providers_totals;
renderKpis(data);
renderMonthlyChart();
renderTenantsTable(allTenants);
setupSearch();
} catch (e) {
document.getElementById("error-banner").innerHTML = `
<div class="banner error"><strong>Erreur de chargement du dashboard</strong> : ${escapeHtml(e.message)}</div>`;
document.getElementById("tenants-table-wrap").innerHTML = "";
}
}
init();