271 lines
8.8 KiB
JavaScript
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();
|