Files
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

239 lines
8.1 KiB
JavaScript

// services.js — page "Services" : répartition des coûts par service, tous
// tenants/providers confondus, avec sélecteur de période (7j/30j/1 an) et
// détail par tenant au clic sur un service.
let currentRange = 30;
let currentProvider = "all";
let currentServices = [];
let currentTotal = 0;
const SORT_COLUMNS = [
{ key: "service_name", label: "Service", numeric: false },
{ key: "amount", label: "Montant", numeric: true },
{ key: "pct", label: "Part", numeric: true },
];
let sortColumn = "amount";
let sortDirection = "desc";
async function loadServices(range, provider) {
const params = new URLSearchParams({ range });
if (provider !== "all") params.set("provider", provider);
const res = await fetch(`/api/services?${params}`);
if (!res.ok) throw new Error(`API services: HTTP ${res.status}`);
return res.json();
}
function periodTitle(data) {
if (data.mode === "monthly") return "12 derniers mois";
return rangeLabel(data.range_days);
}
function sortedServices() {
const sorted = [...currentServices];
sorted.sort((a, b) => {
const cmp =
sortColumn === "service_name"
? a.service_name.localeCompare(b.service_name, "fr", { sensitivity: "base" })
: a[sortColumn] - b[sortColumn];
return sortDirection === "asc" ? cmp : -cmp;
});
return sorted;
}
function sortIndicator(column) {
if (sortColumn !== column) return "";
return sortDirection === "asc" ? " ↑" : " ↓";
}
function renderServicesTable(data) {
document.getElementById("services-period-title").textContent = periodTitle(data);
currentServices = data.services;
currentTotal = data.total;
const wrap = document.getElementById("services-table-wrap");
if (currentServices.length === 0) {
wrap.innerHTML = `<div class="empty-state">Aucune donnée de coût sur cette période. Lance <code>collect.py</code> ou <code>import_historical.py</code> pour peupler le dashboard.</div>`;
return;
}
renderServicesTableRows();
}
function renderServicesTableRows() {
const wrap = document.getElementById("services-table-wrap");
const sorted = sortedServices();
const headerCells = SORT_COLUMNS.map((col) => {
const classes = ["sortable"];
if (col.numeric) classes.push("num");
if (sortColumn === col.key) classes.push(`sorted-${sortDirection}`);
return `<th class="${classes.join(" ")}" data-sort="${col.key}">${col.label}${sortIndicator(col.key)}</th>`;
}).join("");
const rows = sorted
.map(
(s) => `
<tr class="clickable" data-service="${escapeHtml(s.service_name)}" title="Voir le détail par tenant">
<td>${escapeHtml(s.service_name)}</td>
<td class="num">${formatEUR(s.amount)}</td>
<td class="num">${s.pct.toFixed(1)} %</td>
</tr>`
)
.join("");
wrap.innerHTML = `
<table class="data-table">
<thead><tr>${headerCells}</tr></thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td class="num"><strong>${formatEUR(currentTotal)}</strong></td>
<td class="num"></td>
</tr>
</tfoot>
</table>`;
wrap.querySelectorAll("tr.clickable").forEach((tr) => {
tr.addEventListener("click", () => openTenantDrawer(tr.dataset.service));
});
wrap.querySelectorAll("th.sortable").forEach((th) => {
th.addEventListener("click", () => {
const column = th.dataset.sort;
if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortColumn = column;
sortDirection = column === "service_name" ? "asc" : "desc";
}
renderServicesTableRows();
});
});
}
async function reloadServices() {
try {
const data = await loadServices(currentRange, currentProvider);
renderServicesTable(data);
} catch (e) {
document.getElementById("error-banner").innerHTML = `
<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
}
}
function setupRangeToggle() {
const buttons = document.querySelectorAll("#range-toggle button");
buttons.forEach((btn) => {
btn.classList.toggle("active", Number(btn.dataset.range) === currentRange);
btn.addEventListener("click", () => {
currentRange = Number(btn.dataset.range);
buttons.forEach((b) => b.classList.toggle("active", b === btn));
reloadServices();
});
});
}
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));
reloadServices();
});
});
}
// --- Drawer : détail par tenant d'un service ----------------------------
function openTenantDrawer(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("tenant-drawer").classList.add("open");
document.getElementById("tenant-drawer").setAttribute("aria-hidden", "false");
document.getElementById("drawer-backdrop").classList.add("open");
const params = new URLSearchParams({ range: currentRange });
if (currentProvider !== "all") params.set("provider", currentProvider);
fetch(`/api/services/${encodeURIComponent(serviceName)}/tenants?${params}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => renderDrawerContent(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(data) {
const subEl = document.getElementById("drawer-sub");
const bodyEl = document.getElementById("drawer-body");
subEl.textContent = data.mode === "monthly" ? "12 derniers mois" : rangeLabel(data.range_days);
if (!data.tenants.length) {
bodyEl.innerHTML = `<div class="empty-state">Aucun tenant n'a de coût sur ce service pour cette période.</div>`;
return;
}
bodyEl.innerHTML = data.tenants
.map(
(t) => `
<div class="resource-row clickable" data-provider="${t.provider}" data-name="${escapeHtml(t.tenant_name)}">
<div>
<div class="resource-name">
<span class="provider-badge ${t.provider}">${providerLabel(t.provider)}</span>
${escapeHtml(t.tenant_name)}
</div>
<div class="resource-pct">${t.pct.toFixed(1)} %</div>
</div>
<div class="resource-amount">${formatEUR(t.amount)}</div>
</div>`
)
.join("");
bodyEl.querySelectorAll(".resource-row.clickable").forEach((row) => {
row.addEventListener("click", () => {
window.location.href = `/tenant/${row.dataset.provider}/${encodeURIComponent(row.dataset.name)}`;
});
});
}
function closeTenantDrawer() {
document.getElementById("tenant-drawer").classList.remove("open");
document.getElementById("tenant-drawer").setAttribute("aria-hidden", "true");
document.getElementById("drawer-backdrop").classList.remove("open");
}
function setupDrawer() {
document.getElementById("drawer-close").addEventListener("click", closeTenantDrawer);
document.getElementById("drawer-backdrop").addEventListener("click", closeTenantDrawer);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeTenantDrawer();
});
}
async function init() {
try {
const data = await loadServices(currentRange, currentProvider);
renderServicesTable(data);
setupRangeToggle();
setupProviderToggle();
setupDrawer();
} catch (e) {
document.getElementById("error-banner").innerHTML = `
<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
document.getElementById("services-table-wrap").innerHTML = "";
}
}
init();