first (big) draft
This commit is contained in:
@@ -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();
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user