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.
This commit is contained in:
ludal committed 2026-09-20 21:55:52 +02:00
1 parent 9e78dfa4f2
commit c39364eda4
12 files changed
+834 -114

No files matched your search

+54 -29
View File
@@ -5,6 +5,7 @@ let allTenants = [];
let currentMonths = [];
let currentProvidersTotals = { aws: [], azure: [] };
let monthlyChart = null;
let currentProvider = "all";
const selectedMonths = new Set();
async function loadOverview() {
@@ -60,35 +61,44 @@ function renderMonthlyChart() {
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: [
{
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,
},
],
},
data: { labels, datasets },
options: {
...chartBaseOptions(),
onHover: (evt, elements) => {
@@ -240,8 +250,9 @@ function renderTenantsTable(tenants) {
function filteredTenants() {
const q = document.getElementById("tenant-search").value.trim().toLowerCase();
if (!q) return allTenants;
return allTenants.filter((t) => t.name.toLowerCase().includes(q));
return allTenants.filter(
(t) => (currentProvider === "all" || t.provider === currentProvider) && (!q || t.name.toLowerCase().includes(q))
);
}
function setupSearch() {
@@ -250,6 +261,19 @@ function setupSearch() {
});
}
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();
@@ -258,8 +282,9 @@ async function init() {
currentProvidersTotals = data.providers_totals;
renderKpis(data);
renderMonthlyChart();
renderTenantsTable(allTenants);
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>`;
+4
View File
@@ -43,6 +43,10 @@ function dayLabel(isoDate) {
return new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", timeZone: "UTC" }).format(d);
}
function rangeLabel(days) {
return days === 1 ? "aujourd'hui" : `${days} derniers jours`;
}
function providerLabel(provider) {
return provider === "aws" ? "AWS" : provider === "azure" ? "Azure" : provider;
}
+238
View File
@@ -0,0 +1,238 @@
// 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();
+128 -22
View File
@@ -132,11 +132,103 @@ a {
}
.container {
max-width: 1240px;
/* width: 100% est nécessaire ici : .page (son parent) est en
display:flex, et des marges auto sur l'axe transversal désactivent
l'étirement flex par défaut — sans ce width, .container retombe sur
un calcul "taille selon son contenu" au lieu de remplir l'espace
disponible jusqu'à max-width. */
width: 100%;
max-width: 1520px;
margin: 0 auto;
padding: 28px 32px 64px;
}
/* ---------------------------------------------------------------- */
/* Sidebar de navigation */
/* ---------------------------------------------------------------- */
.app-shell {
display: flex;
min-height: 100vh;
align-items: stretch;
}
.sidebar {
width: 220px;
flex: none;
background: var(--surface-1);
border-right: 1px solid var(--border);
padding: 20px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar-brand {
padding: 0 20px 16px;
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid var(--border);
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 12px;
}
.sidebar-link {
display: block;
padding: 9px 12px;
border-radius: var(--radius-control);
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
font-weight: 500;
}
.sidebar-link:hover {
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
color: var(--text-primary);
}
.sidebar-link.active {
background: color-mix(in srgb, var(--series-1) 14%, transparent);
color: var(--series-1);
}
.page {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
@media (max-width: 700px) {
.app-shell {
flex-direction: column;
min-height: 0;
}
.sidebar {
width: auto;
border-right: none;
border-bottom: 1px solid var(--border);
padding: 12px;
}
.sidebar-brand {
display: none;
}
.sidebar-nav {
flex-direction: row;
padding: 0;
}
}
.section-title {
font-size: 13px;
font-weight: 600;
@@ -207,23 +299,6 @@ a {
display: none;
}
/* ---------------------------------------------------------------- */
/* Grille de graphiques */
/* ---------------------------------------------------------------- */
.chart-grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 16px;
align-items: stretch;
}
@media (max-width: 860px) {
.chart-grid {
grid-template-columns: 1fr;
}
}
.chart-card {
padding: 20px 20px 12px;
display: flex;
@@ -299,14 +374,14 @@ a {
outline-offset: -1px;
}
.range-toggle {
.segmented-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-control);
overflow: hidden;
}
.range-toggle button {
.segmented-toggle button {
border: none;
background: transparent;
color: var(--text-secondary);
@@ -316,11 +391,11 @@ a {
font-family: inherit;
}
.range-toggle button + button {
.segmented-toggle button + button {
border-left: 1px solid var(--border);
}
.range-toggle button.active {
.segmented-toggle button.active {
background: var(--series-1);
color: #ffffff;
font-weight: 500;
@@ -369,6 +444,21 @@ table.data-table {
white-space: nowrap;
}
.data-table thead th.sortable {
cursor: pointer;
user-select: none;
}
.data-table thead th.sortable:hover {
color: var(--text-primary);
}
.data-table thead th.sorted-asc,
.data-table thead th.sorted-desc {
color: var(--text-primary);
font-weight: 600;
}
.data-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--gridline);
@@ -379,6 +469,11 @@ table.data-table {
border-bottom: none;
}
.data-table tfoot td {
padding: 10px 12px;
border-top: 1px solid var(--gridline);
}
.data-table tbody tr.clickable {
cursor: pointer;
}
@@ -578,6 +673,17 @@ table.data-table {
border-bottom: none;
}
.resource-row.clickable {
cursor: pointer;
margin: 0 -8px;
padding: 10px 8px;
border-radius: var(--radius-control);
}
.resource-row.clickable:hover {
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
}
.resource-name {
font-size: 13px;
overflow-wrap: anywhere;
+4 -3
View File
@@ -68,12 +68,13 @@ function renderKpis(data) {
if (isMonthly) {
document.getElementById("kpi-current-label").textContent = `Total — ${p.months.length} derniers mois`;
} else {
document.getElementById("kpi-current-label").textContent = `Total — ${p.range_days} derniers jours`;
document.getElementById("kpi-current-label").textContent = `Total — ${rangeLabel(p.range_days)}`;
}
document.getElementById("kpi-current").textContent = formatEUR(p.current_total);
if (!isMonthly) {
document.getElementById("kpi-previous-label").textContent = `Total — ${p.range_days} jours précédents`;
document.getElementById("kpi-previous-label").textContent =
p.range_days === 1 ? "Total — veille" : `Total — ${p.range_days} jours précédents`;
document.getElementById("kpi-previous").textContent = p.has_previous ? formatEUR(p.previous_total) : "—";
const deltaEl = document.getElementById("kpi-delta");
@@ -99,7 +100,7 @@ function renderDailyChart(data) {
document.getElementById("daily-chart-title").textContent = isMonthly
? `Coûts mensuels — ${p.months.length} derniers mois`
: `Coûts journaliers — ${p.range_days} derniers jours`;
: `Coûts journaliers — ${rangeLabel(p.range_days)}`;
if (dailyChart) {
dailyChart.destroy();