Files
simple-cost-dashboard/frontend/static/app.js
T
ludal c39364eda4 feat: add services page and API endpoints for service cost breakdown
- Implemented new API endpoints for fetching global services and service tenants details.
- Created a new services.html page with a sidebar for navigation and a chart for service costs.
- Updated frontend JavaScript to handle service data loading and rendering.
- Enhanced the dashboard with provider filtering options for AWS and Azure.
- Added a segmented toggle for selecting the date range for service costs.
- Improved styling for the sidebar and data tables.
- Refactored common utility functions for better reusability across pages.
2026-09-20 21:55:52 +02:00

296 lines
9.9 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;
let currentProvider = "all";
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 datasets = [];
if (currentProvider === "all" || currentProvider === "aws") {
datasets.push({
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,
});
}
if (currentProvider === "all" || currentProvider === "azure") {
datasets.push({
label: "Azure",
data: currentProvidersTotals.azure,
backgroundColor: colorFor(azureColor),
borderColor: surface,
borderWidth: { top: 2, right: 0, bottom: 0, left: 0 },
borderSkipped: false,
// Arrondi uniquement quand Azure est seul affiché (filtre "Azure") —
// en vue empilée "Tous", il est au-dessus d'AWS et déjà arrondi par
// le borderRadius du dataset AWS en dessous.
borderRadius: currentProvider === "azure" ? { topLeft: 4, topRight: 4 } : undefined,
stack: "total",
maxBarThickness: 24,
});
}
document.getElementById("legend-aws").style.display = currentProvider === "azure" ? "none" : "";
document.getElementById("legend-azure").style.display = currentProvider === "aws" ? "none" : "";
const ctx = document.getElementById("monthly-chart").getContext("2d");
monthlyChart = new Chart(ctx, {
type: "bar",
data: { labels, datasets },
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();
return allTenants.filter(
(t) => (currentProvider === "all" || t.provider === currentProvider) && (!q || t.name.toLowerCase().includes(q))
);
}
function setupSearch() {
document.getElementById("tenant-search").addEventListener("input", () => {
renderTenantsTable(filteredTenants());
});
}
function setupProviderToggle() {
const buttons = document.querySelectorAll("#provider-toggle button");
buttons.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.provider === currentProvider);
btn.addEventListener("click", () => {
currentProvider = btn.dataset.provider;
buttons.forEach((b) => b.classList.toggle("active", b === btn));
renderMonthlyChart();
renderTenantsTable(filteredTenants());
});
});
}
async function init() {
try {
const data = await loadOverview();
allTenants = data.tenants;
currentMonths = data.months;
currentProvidersTotals = data.providers_totals;
renderKpis(data);
renderMonthlyChart();
renderTenantsTable(filteredTenants());
setupSearch();
setupProviderToggle();
} 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();