- 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.
239 lines
8.1 KiB
JavaScript
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();
|