first (big) draft

This commit is contained in:
2026-08-07 17:19:49 +02:00
parent 2974a3a4cd
commit 9e78dfa4f2
24 changed files with 3975 additions and 1 deletions
+270
View File
@@ -0,0 +1,270 @@
// 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();
+96
View File
@@ -0,0 +1,96 @@
// common.js — petits utilitaires partagés entre app.js (page globale)
// et tenant.js (page détail tenant).
const eurFormatter = new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 2,
});
function formatEUR(amount) {
if (amount === null || amount === undefined) return "—";
return eurFormatter.format(amount);
}
function formatPct(value) {
if (value === null || value === undefined) return null;
const sign = value > 0 ? "+" : "";
return `${sign}${value.toFixed(1)} %`;
}
function deltaClass(value) {
if (value === null || value === undefined) return "flat";
if (value > 0.5) return "up";
if (value < -0.5) return "down";
return "flat";
}
function deltaArrow(value) {
if (value === null || value === undefined) return "";
if (value > 0.5) return "↑";
if (value < -0.5) return "↓";
return "→";
}
function monthLabel(monthKey) {
const [y, m] = monthKey.split("-").map(Number);
const d = new Date(Date.UTC(y, m - 1, 1));
return new Intl.DateTimeFormat("fr-FR", { month: "short", year: "2-digit", timeZone: "UTC" }).format(d);
}
function dayLabel(isoDate) {
const d = new Date(`${isoDate}T00:00:00Z`);
return new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", timeZone: "UTC" }).format(d);
}
function providerLabel(provider) {
return provider === "aws" ? "AWS" : provider === "azure" ? "Azure" : provider;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
// Config partagée des axes/grilles Chart.js (gridlines discrètes, pas de
// deuxième axe, tooltip alignée sur le design system).
function chartBaseOptions() {
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: cssVar("--surface-1"),
titleColor: cssVar("--text-primary"),
bodyColor: cssVar("--text-secondary"),
borderColor: cssVar("--border"),
borderWidth: 1,
padding: 10,
displayColors: true,
boxPadding: 4,
},
},
scales: {
x: {
grid: { display: false },
border: { color: cssVar("--axis") },
ticks: { color: cssVar("--text-muted"), font: { size: 11 } },
},
y: {
beginAtZero: true,
grid: { color: cssVar("--gridline") },
border: { display: false },
ticks: { color: cssVar("--text-muted"), font: { size: 11 } },
},
},
};
}
+606
View File
@@ -0,0 +1,606 @@
/* simple-cost-dashboard — design tokens + composants partagés
(page globale et page détail tenant) */
:root {
color-scheme: light;
--surface-1: #fcfcfb; /* cartes */
--plane: #f9f9f7; /* fond de page */
--text-primary: #0b0b0b;
--text-secondary: #52514e;
--text-muted: #898781;
--gridline: #e1e0d9;
--axis: #c3c2b7;
--border: rgba(11, 11, 11, 0.10);
--delta-good: #006300;
--delta-bad: #d03b3b;
--status-good: #0ca30c;
--status-warning: #fab219;
--status-serious: #ec835a;
--status-critical: #d03b3b;
--provider-aws: #eb6834; /* orange — identité AWS */
--provider-azure: #2a78d6; /* bleu — identité Azure */
--series-1: #2a78d6;
--series-2: #eb6834;
--series-3: #1baf7a;
--series-4: #eda100;
--series-5: #e87ba4;
--series-6: #008300;
--series-7: #4a3aa7;
--series-8: #e34948;
--series-other: #c3c2b7;
--radius-card: 12px;
--radius-control: 8px;
--shadow-card: 0 1px 2px rgba(11, 11, 11, 0.04), 0 1px 8px rgba(11, 11, 11, 0.04);
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--surface-1: #1a1a19;
--plane: #0d0d0d;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--gridline: #2c2c2a;
--axis: #383835;
--border: rgba(255, 255, 255, 0.10);
--delta-good: #0ca30c;
--delta-bad: #e66767;
--provider-aws: #d95926;
--provider-azure: #3987e5;
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--series-7: #9085e9;
--series-8: #e66767;
--series-other: #383835;
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.2), 0 1px 8px rgba(0, 0, 0, 0.2);
}
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
}
body {
background: var(--plane);
color: var(--text-primary);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.45;
}
a {
color: inherit;
}
/* ---------------------------------------------------------------- */
/* Layout */
/* ---------------------------------------------------------------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 32px;
border-bottom: 1px solid var(--border);
background: var(--surface-1);
}
.topbar-title {
display: flex;
align-items: baseline;
gap: 10px;
}
.topbar-title h1 {
font-size: 17px;
font-weight: 600;
margin: 0;
}
.topbar-title .breadcrumb {
color: var(--text-muted);
font-size: 13px;
}
.topbar-title .breadcrumb a {
color: var(--text-secondary);
text-decoration: none;
}
.topbar-title .breadcrumb a:hover {
text-decoration: underline;
}
.container {
max-width: 1240px;
margin: 0 auto;
padding: 28px 32px 64px;
}
.section-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 32px 0 12px;
}
.section-title:first-child {
margin-top: 0;
}
/* ---------------------------------------------------------------- */
/* Cartes / KPI */
/* ---------------------------------------------------------------- */
.card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
.kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.kpi-card {
padding: 18px 20px;
}
.kpi-label {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 8px;
}
.kpi-value {
font-size: 26px;
font-weight: 600;
margin: 0;
font-variant-numeric: proportional-nums;
}
.kpi-delta {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
margin-top: 6px;
}
.kpi-delta.up { color: var(--delta-bad); }
.kpi-delta.down { color: var(--delta-good); }
.kpi-delta.flat { color: var(--text-muted); }
.kpi-sub {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
}
.kpi-row.no-comparison .kpi-card-comparison {
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;
flex-direction: column;
}
.chart-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
flex-wrap: wrap;
}
.chart-card-header h2 {
font-size: 14px;
font-weight: 600;
margin: 0;
}
.chart-card-sub {
font-size: 12px;
color: var(--text-muted);
margin: 2px 0 0;
}
.chart-canvas-wrap {
position: relative;
flex: 1;
min-height: 220px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: var(--text-secondary);
margin-top: 10px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex: none;
}
/* ---------------------------------------------------------------- */
/* Contrôles (recherche, plage 7j/30j) */
/* ---------------------------------------------------------------- */
.search-input {
width: 240px;
max-width: 100%;
padding: 7px 10px;
border-radius: var(--radius-control);
border: 1px solid var(--border);
background: var(--surface-1);
color: var(--text-primary);
font-size: 13px;
}
.search-input:focus {
outline: 2px solid var(--series-1);
outline-offset: -1px;
}
.range-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-control);
overflow: hidden;
}
.range-toggle button {
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
padding: 6px 14px;
cursor: pointer;
font-family: inherit;
}
.range-toggle button + button {
border-left: 1px solid var(--border);
}
.range-toggle button.active {
background: var(--series-1);
color: #ffffff;
font-weight: 500;
}
.selection-note {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
}
.selection-note button {
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
font-size: 12px;
padding: 4px 10px;
border-radius: var(--radius-control);
cursor: pointer;
font-family: inherit;
}
.selection-note button:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
}
/* ---------------------------------------------------------------- */
/* Tables */
/* ---------------------------------------------------------------- */
table.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table thead th {
text-align: left;
font-weight: 500;
color: var(--text-muted);
padding: 8px 12px;
border-bottom: 1px solid var(--gridline);
white-space: nowrap;
}
.data-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--gridline);
vertical-align: middle;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.data-table tbody tr.clickable {
cursor: pointer;
}
.data-table tbody tr.clickable:hover {
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.provider-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 2px 7px;
border-radius: 999px;
flex: none;
}
.provider-badge.aws {
color: var(--provider-aws);
background: color-mix(in srgb, var(--provider-aws) 14%, transparent);
}
.provider-badge.azure {
color: var(--provider-azure);
background: color-mix(in srgb, var(--provider-azure) 14%, transparent);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex: none;
}
.status-dot.ok { background: var(--status-good); }
.status-dot.error { background: var(--status-critical); }
.status-dot.unknown { background: var(--text-muted); }
.status-label {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
/* ---------------------------------------------------------------- */
/* Bandeaux d'état / vides */
/* ---------------------------------------------------------------- */
.banner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
border-radius: var(--radius-control);
font-size: 13px;
margin-bottom: 20px;
}
.banner.error {
background: color-mix(in srgb, var(--status-critical) 12%, var(--surface-1));
color: var(--text-primary);
border: 1px solid color-mix(in srgb, var(--status-critical) 30%, transparent);
}
.banner strong {
font-weight: 600;
}
.empty-state {
padding: 40px 20px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
.skeleton {
padding: 40px 20px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
/* ---------------------------------------------------------------- */
/* Panneau latéral (drawer) — détail par ressource d'un service */
/* ---------------------------------------------------------------- */
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 40;
}
.drawer-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 440px;
max-width: 92vw;
background: var(--surface-1);
border-left: 1px solid var(--border);
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.14);
transform: translateX(100%);
transition: transform 0.2s ease;
z-index: 41;
display: flex;
flex-direction: column;
}
.drawer.open {
transform: translateX(0);
}
.drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 20px;
border-bottom: 1px solid var(--border);
flex: none;
}
.drawer-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
word-break: break-word;
}
.drawer-header p {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-muted);
}
.drawer-close {
border: none;
background: transparent;
color: var(--text-muted);
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-control);
flex: none;
}
.drawer-close:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: 8px 20px 20px;
}
.resource-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--gridline);
}
.resource-row:last-child {
border-bottom: none;
}
.resource-name {
font-size: 13px;
overflow-wrap: anywhere;
}
.resource-pct {
color: var(--text-muted);
font-size: 12px;
}
.resource-amount {
font-variant-numeric: tabular-nums;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
text-align: right;
flex: none;
}
/* ---------------------------------------------------------------- */
/* Chart.js — surcharges de style (couleurs de police/grille) */
/* ---------------------------------------------------------------- */
.chartjs-tooltip {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
+497
View File
@@ -0,0 +1,497 @@
// tenant.js — page détail tenant : KPIs, graphique journalier (7j/30j),
// répartition par service (30 derniers jours).
const SERIES_COLORS = [
"--series-1", "--series-2", "--series-3", "--series-4",
"--series-5", "--series-6", "--series-7", "--series-8",
];
const MAX_SERVICE_SLOTS = 8;
const pathParts = window.location.pathname.split("/").filter(Boolean); // ["tenant", provider, name]
const PROVIDER = decodeURIComponent(pathParts[1] || "");
const NAME = decodeURIComponent(pathParts[2] || "");
let currentRange = 30;
let currentBuckets = [];
let currentMode = "daily"; // "daily" | "monthly"
let defaultServices = {};
const selectedBuckets = new Set();
let dailyChart = null;
let servicesChart = null;
async function loadDetail(range) {
const res = await fetch(`/api/tenant/${PROVIDER}/${encodeURIComponent(NAME)}?range=${range}`);
if (!res.ok) throw new Error(`API tenant: HTTP ${res.status}`);
return res.json();
}
function renderHeader(data) {
document.title = `${NAME} — simple-cost-dashboard`;
document.getElementById("tenant-title").innerHTML =
`<span class="provider-badge ${data.tenant.provider}">${providerLabel(data.tenant.provider)}</span> ${escapeHtml(data.tenant.name)}`;
}
function renderStatusBanner(data) {
const el = document.getElementById("status-banner");
const status = data.status;
const isMonthly = data.period.mode === "monthly";
const hasAnyData = isMonthly ? data.has_monthly_data : data.has_data;
if (status && status.status === "error") {
el.innerHTML = `
<div class="banner error">
<div>
<strong>Dernière collecte en échec</strong> (${escapeHtml(status.period_start || "")}) :
${escapeHtml(status.error_message || "erreur inconnue.")}
</div>
</div>`;
} else if (!hasAnyData) {
const msg = isMonthly
? "Aucune donnée mensuelle pour ce tenant — lance <code>backfill_historical.py</code> ou <code>import_historical.py</code> pour peupler l'historique."
: "Pas encore de données journalières pour ce tenant — la collecte quotidienne n'a pas encore tourné, ou seules des données historiques mensuelles ont été importées.";
el.innerHTML = `
<div class="banner error">
<div><strong>Pas encore de données</strong> — ${msg}</div>
</div>`;
} else {
el.innerHTML = "";
}
}
function renderKpis(data) {
const p = data.period;
const isMonthly = p.mode === "monthly";
const kpiRow = document.getElementById("kpi-row");
kpiRow.classList.toggle("no-comparison", isMonthly);
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").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").textContent = p.has_previous ? formatEUR(p.previous_total) : "—";
const deltaEl = document.getElementById("kpi-delta");
const delta = formatPct(p.delta_pct);
deltaEl.textContent = delta || "—";
deltaEl.className = `kpi-value kpi-delta ${deltaClass(p.delta_pct)}`;
}
document.getElementById("kpi-ytd").textContent = formatEUR(data.ytd_total);
document.getElementById("kpi-ytd-label").textContent = `Total depuis le 1er janvier ${new Date().getFullYear()}`;
}
function dimmedColor(hex) {
return Chart.helpers.color(hex).alpha(0.3).rgbString();
}
function renderDailyChart(data) {
const p = data.period;
const isMonthly = p.mode === "monthly";
currentMode = isMonthly ? "monthly" : "daily";
currentBuckets = isMonthly ? p.months : p.days;
const labelFn = isMonthly ? monthLabel : dayLabel;
document.getElementById("daily-chart-title").textContent = isMonthly
? `Coûts mensuels — ${p.months.length} derniers mois`
: `Coûts journaliers — ${p.range_days} derniers jours`;
if (dailyChart) {
dailyChart.destroy();
dailyChart = null;
}
const wrap = document.querySelector("#daily-chart").parentElement;
if (!currentBuckets.length) {
wrap.innerHTML = `<div class="empty-state">Aucune donnée sur cette période.</div>`;
return;
}
if (!wrap.querySelector("canvas")) {
wrap.innerHTML = `<canvas id="daily-chart"></canvas>`;
}
const ctx = document.getElementById("daily-chart").getContext("2d");
const accent = cssVar("--series-1");
const surface = cssVar("--surface-1");
dailyChart = new Chart(ctx, {
type: "bar",
data: {
labels: currentBuckets.map(labelFn),
datasets: [
{
label: "Coût",
data: p.amounts,
backgroundColor: (ctx2) => {
if (selectedBuckets.size === 0) return accent;
return selectedBuckets.has(currentBuckets[ctx2.dataIndex]) ? accent : dimmedColor(accent);
},
borderColor: surface,
borderWidth: { top: 2, right: 0, bottom: 0, left: 0 },
borderSkipped: false,
borderRadius: { topLeft: 4, topRight: 4 },
maxBarThickness: isMonthly ? 32 : 18,
},
],
},
options: {
...chartBaseOptions(),
onHover: (evt, elements) => {
evt.native.target.style.cursor = elements.length ? "pointer" : "default";
},
onClick: (evt, elements) => {
if (!elements.length) return;
const bucket = currentBuckets[elements[0].index];
if (selectedBuckets.has(bucket)) {
selectedBuckets.delete(bucket);
} else {
selectedBuckets.add(bucket);
}
// Reporté au tick suivant : détruire ce chart depuis son propre
// handler onClick corromprait les listeners internes de Chart.js.
setTimeout(() => {
renderDailyChart(data);
refreshServicesSection();
}, 0);
},
plugins: {
...chartBaseOptions().plugins,
tooltip: {
...chartBaseOptions().plugins.tooltip,
callbacks: {
label: (item) => `Coût : ${formatEUR(item.parsed.y)}`,
},
},
},
},
});
}
function bucketServices(services) {
const entries = Object.entries(services);
if (entries.length <= MAX_SERVICE_SLOTS) {
return { top: entries, otherTotal: 0 };
}
const top = entries.slice(0, MAX_SERVICE_SLOTS - 1);
const rest = entries.slice(MAX_SERVICE_SLOTS - 1);
const otherTotal = rest.reduce((sum, [, amount]) => sum + amount, 0);
return { top, otherTotal };
}
function renderServicesChart(services) {
const wrap = document.querySelector("#services-chart").parentElement;
const entries = Object.entries(services);
if (entries.length === 0) {
wrap.innerHTML = `<div class="empty-state">Aucun coût par service sur cette période.</div>`;
return;
}
const { top, otherTotal } = bucketServices(services);
const labels = top.map(([name]) => name).concat(otherTotal > 0 ? ["Autres"] : []);
const values = top.map(([, amount]) => amount).concat(otherTotal > 0 ? [otherTotal] : []);
const colors = top.map((_, i) => cssVar(SERIES_COLORS[i])).concat(otherTotal > 0 ? [cssVar("--series-other")] : []);
if (servicesChart) {
servicesChart.destroy();
servicesChart = null;
}
if (!wrap.querySelector("canvas")) {
wrap.innerHTML = `<canvas id="services-chart"></canvas>`;
}
const ctx = document.getElementById("services-chart").getContext("2d");
servicesChart = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [
{
data: values,
backgroundColor: colors,
maxBarThickness: 20,
borderRadius: 4,
borderSkipped: false,
},
],
},
options: {
indexAxis: "y",
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "nearest", intersect: true },
onHover: (evt, elements) => {
evt.native.target.style.cursor = elements.length && labels[elements[0].index] !== "Autres" ? "pointer" : "default";
},
onClick: (evt, elements) => {
if (!elements.length) return;
const label = labels[elements[0].index];
if (label === "Autres") return;
openResourceDrawer(label);
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: cssVar("--surface-1"),
titleColor: cssVar("--text-primary"),
bodyColor: cssVar("--text-secondary"),
borderColor: cssVar("--border"),
borderWidth: 1,
padding: 10,
callbacks: { label: (item) => formatEUR(item.parsed.x) },
},
},
scales: {
x: {
beginAtZero: true,
grid: { color: cssVar("--gridline") },
border: { display: false },
ticks: { color: cssVar("--text-muted"), font: { size: 11 } },
},
y: {
grid: { display: false },
border: { color: cssVar("--axis") },
ticks: { color: cssVar("--text-muted"), font: { size: 11 } },
},
},
},
});
}
function renderServicesTable(services) {
const wrap = document.getElementById("services-table-wrap");
const entries = Object.entries(services);
document.getElementById("services-chart-sub").textContent =
entries.length > 0 ? `${entries.length} service${entries.length > 1 ? "s" : ""} identifié${entries.length > 1 ? "s" : ""}` : "";
if (entries.length === 0) {
wrap.innerHTML = `<div class="empty-state">Rien à afficher.</div>`;
return;
}
const total = entries.reduce((sum, [, amount]) => sum + amount, 0) || 1;
const rows = entries
.map(([name, amount]) => {
const pct = (amount / total) * 100;
return `
<tr class="clickable" data-service="${escapeHtml(name)}" title="Voir le détail par ressource">
<td>${escapeHtml(name)}</td>
<td class="num">${formatEUR(amount)}</td>
<td class="num">${pct.toFixed(1)} %</td>
</tr>`;
})
.join("");
wrap.innerHTML = `
<table class="data-table">
<thead>
<tr><th>Service</th><th class="num">Montant</th><th class="num">%</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
wrap.querySelectorAll("tr.clickable").forEach((tr) => {
tr.addEventListener("click", () => openResourceDrawer(tr.dataset.service));
});
}
// --- Sélection de barres (jours/mois) -> filtre la répartition service --
function selectedBucketsInOrder() {
return currentBuckets.filter((b) => selectedBuckets.has(b));
}
function renderServicesSelectionNote() {
const el = document.getElementById("services-selection-note");
const titleEl = document.getElementById("services-section-title");
if (selectedBuckets.size === 0) {
el.innerHTML = "";
titleEl.textContent = "Répartition par service (30 derniers jours)";
return;
}
const labelFn = currentMode === "monthly" ? monthLabel : dayLabel;
const ordered = selectedBucketsInOrder();
const noun = currentMode === "monthly" ? "mois" : "jour";
titleEl.textContent = `Répartition par service — ${ordered.length} ${noun}${ordered.length > 1 ? "s" : ""} sélectionné${ordered.length > 1 ? "s" : ""}`;
el.innerHTML = `
<span>Sélection : ${ordered.map(labelFn).join(", ")}</span>
<button type="button" id="clear-services-selection">Réinitialiser</button>`;
document.getElementById("clear-services-selection").addEventListener("click", () => {
selectedBuckets.clear();
if (dailyChart) {
dailyChart.update();
}
refreshServicesSection();
});
}
async function refreshServicesSection() {
renderServicesSelectionNote();
if (selectedBuckets.size === 0) {
renderServicesChart(defaultServices);
renderServicesTable(defaultServices);
return;
}
const ordered = selectedBucketsInOrder();
const param = currentMode === "monthly"
? `months=${ordered.map(encodeURIComponent).join(",")}`
: `days=${ordered.map(encodeURIComponent).join(",")}`;
try {
const res = await fetch(`/api/tenant/${PROVIDER}/${encodeURIComponent(NAME)}/services?${param}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
renderServicesChart(data.services);
renderServicesTable(data.services);
} catch (e) {
document.getElementById("services-table-wrap").innerHTML =
`<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
}
}
// --- Drawer : détail par ressource d'un service ------------------------
function openResourceDrawer(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("resource-drawer").classList.add("open");
document.getElementById("resource-drawer").setAttribute("aria-hidden", "false");
document.getElementById("drawer-backdrop").classList.add("open");
fetch(`/api/tenant/${PROVIDER}/${encodeURIComponent(NAME)}/resources/${encodeURIComponent(serviceName)}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => renderDrawerContent(serviceName, 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(serviceName, data) {
const subEl = document.getElementById("drawer-sub");
const bodyEl = document.getElementById("drawer-body");
if (data.period_start && data.period_end) {
subEl.textContent = `${dayLabel(data.period_start)}${dayLabel(data.period_end)}`;
} else {
subEl.textContent = "";
}
if (data.status === "never_collected") {
bodyEl.innerHTML = `<div class="empty-state">
Le détail par ressource n'a pas encore été collecté pour ce tenant —
il arrive au prochain passage de la collecte quotidienne.
</div>`;
return;
}
if (data.status === "unavailable") {
const hint = PROVIDER === "aws"
? " Vérifie que «Resource IDs» est activé dans les préférences Cost Explorer de ce compte AWS."
: "";
bodyEl.innerHTML = `<div class="banner error">
<div><strong>Détail par ressource indisponible</strong> pour ce service.${escapeHtml(hint)}<br>
${escapeHtml(data.message || "")}</div>
</div>`;
return;
}
if (data.status === "error") {
bodyEl.innerHTML = `<div class="banner error">
<div><strong>Échec de la dernière collecte du détail ressources</strong> :
${escapeHtml(data.message || "erreur inconnue")}</div>
</div>`;
return;
}
if (!data.resources.length) {
bodyEl.innerHTML = `<div class="empty-state">Aucune ressource identifiée pour ce service sur cette période.</div>`;
return;
}
const total = data.resources.reduce((sum, r) => sum + r.amount, 0) || 1;
bodyEl.innerHTML = data.resources
.map((r) => {
const pct = (r.amount / total) * 100;
return `
<div class="resource-row" title="${escapeHtml(r.resource_id)}">
<div>
<div class="resource-name">${escapeHtml(r.resource_name)}</div>
<div class="resource-pct">${pct.toFixed(1)} %</div>
</div>
<div class="resource-amount">${formatEUR(r.amount)}</div>
</div>`;
})
.join("");
}
function closeResourceDrawer() {
document.getElementById("resource-drawer").classList.remove("open");
document.getElementById("resource-drawer").setAttribute("aria-hidden", "true");
document.getElementById("drawer-backdrop").classList.remove("open");
}
function setupDrawer() {
document.getElementById("drawer-close").addEventListener("click", closeResourceDrawer);
document.getElementById("drawer-backdrop").addEventListener("click", closeResourceDrawer);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeResourceDrawer();
});
}
function setupRangeToggle() {
const buttons = document.querySelectorAll("#range-toggle button");
buttons.forEach((btn) => {
btn.classList.toggle("active", Number(btn.dataset.range) === currentRange);
btn.addEventListener("click", async () => {
currentRange = Number(btn.dataset.range);
buttons.forEach((b) => b.classList.toggle("active", b === btn));
selectedBuckets.clear();
const data = await loadDetail(currentRange);
defaultServices = data.services;
renderKpis(data);
renderDailyChart(data);
refreshServicesSection();
});
});
}
async function init() {
if (!PROVIDER || !NAME) {
document.getElementById("status-banner").innerHTML =
`<div class="banner error"><strong>URL invalide.</strong></div>`;
return;
}
try {
const data = await loadDetail(currentRange);
defaultServices = data.services;
renderHeader(data);
renderStatusBanner(data);
renderKpis(data);
renderDailyChart(data);
refreshServicesSection();
setupRangeToggle();
setupDrawer();
} catch (e) {
document.getElementById("status-banner").innerHTML =
`<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
}
}
init();