diff --git a/.gitignore b/.gitignore index 37921d2..9b7c433 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ config.json data/ .env .DS_Store -.claude/ \ No newline at end of file +.claude/ +TODO.md \ No newline at end of file diff --git a/README.md b/README.md index 3db39c9..9f25bf4 100644 --- a/README.md +++ b/README.md @@ -252,39 +252,3 @@ simple-cost-dashboard/ │ └── tenant.js # logique page détail + graphes + drawer ressources └── deploy/ # à venir : manifestes Kubernetes (voir TODO) ``` - -## 9. À faire - -**Déploiement Kubernetes** (`deploy/`, pas encore créé) : -- [ ] Manifeste `Deployment` pour le serveur web (readiness/liveness sur `/health`) -- [ ] Manifeste `Service` + `Ingress` -- [ ] Manifeste `CronJob` pour la collecte quotidienne, même image, commande surchargée -- [ ] `PersistentVolumeClaim` pour `/data` (la base SQLite doit survivre aux redéploiements) -- [ ] `Secret` Kubernetes pour `config.json` (credentials Azure notamment) plutôt qu'un ConfigMap en clair - -**Authentification** : -- [ ] Basic Auth via l'Ingress (annotation nginx `auth-basic` + `Secret` `htpasswd`) — solution de départ validée -- [ ] Bascule vers SSO Entra ID (app d'entreprise Azure + groupe) une fois le besoin confirmé — probablement via `oauth2-proxy` en sidecar/Ingress plutôt que dans le code de l'appli, pour ne pas coupler l'auth à FastAPI - -**Credentials AWS/Azure en environnement conteneurisé** : -- [x] AWS : tranché pour des clés IAM dédiées par tenant (`access_key_id`/ - `secret_access_key` dans `config.json`, droit `ce:GetCostAndUsage` - uniquement) plutôt que des profils `~/.aws` — les profils SSO ne - fonctionnent pas en conteneur (le refresh du token demande un - navigateur). Voir §2. Pas encore fait : créer ces clés IAM dédiées côté - AWS pour chaque tenant (elles n'existent pas encore, seuls des profils - SSO existent aujourd'hui) — et envisager `sts:AssumeRole` depuis une - identité unique si la rotation de 28+ clés IAM statiques devient trop - lourde à gérer. -- [ ] `config.json` (les clés AWS et les `client_secret` Azure) doit passer - en `Secret` Kubernetes monté en volume une fois `deploy/` créé, pas en - ConfigMap. - -**Fiabilité / observabilité** : -- [ ] Alerte (Slack/email) si un tenant est en échec de collecte plusieurs jours de suite — actuellement visible seulement en se rendant sur le dashboard -- [ ] Sauvegarde régulière de la base SQLite (le PVC seul ne protège pas d'une corruption) -- [ ] Limite de rétention ou archivage si le volume de la base grossit significativement à long terme (non urgent : le volume attendu reste faible) - -**Fonctionnel** : -- [ ] Vérifier le rendu du dashboard avec de vraies données (actuellement testé uniquement sur la structure du code, pas sur un run réel bout-en-bout) -- [ ] Décider si la page globale doit aussi permettre de filtrer/rechercher un tenant si leur nombre grossit beaucoup \ No newline at end of file diff --git a/api/main.py b/api/main.py index 782c859..6fbb3bf 100644 --- a/api/main.py +++ b/api/main.py @@ -52,6 +52,11 @@ def tenant_page(provider: str, name: str): return _read_page("tenant.html") +@app.get("/services", response_class=HTMLResponse) +def services_page(): + return _read_page("services.html") + + @app.get("/health") def health(): try: @@ -74,11 +79,43 @@ def api_overview(months: int = Query(12, ge=1, le=24)): conn.close() +VALID_RANGES = (1, 7, 30, 365) + + +def _validate_provider_filter(provider: str | None) -> None: + if provider is not None and provider not in PROVIDERS: + raise HTTPException(status_code=404, detail=f"Provider inconnu : {provider}") + + +@app.get("/api/services") +def api_services(range: int = Query(30, alias="range"), provider: str | None = Query(None)): + if range not in VALID_RANGES: + raise HTTPException(status_code=400, detail="range doit être 1, 7, 30 ou 365") + _validate_provider_filter(provider) + conn = queries.get_connection() + try: + return queries.global_services(conn, range_days=range, provider=provider) + finally: + conn.close() + + +@app.get("/api/services/{service_name}/tenants") +def api_service_tenants(service_name: str, range: int = Query(30, alias="range"), provider: str | None = Query(None)): + if range not in VALID_RANGES: + raise HTTPException(status_code=400, detail="range doit être 1, 7, 30 ou 365") + _validate_provider_filter(provider) + conn = queries.get_connection() + try: + return queries.service_tenants_detail(conn, service_name, range_days=range, provider=provider) + finally: + conn.close() + + @app.get("/api/tenant/{provider}/{name}") def api_tenant_detail(provider: str, name: str, range: int = Query(30, alias="range")): _require_provider(provider) - if range not in (7, 30, 365): - raise HTTPException(status_code=400, detail="range doit être 7, 30 ou 365") + if range not in VALID_RANGES: + raise HTTPException(status_code=400, detail="range doit être 1, 7, 30 ou 365") conn = queries.get_connection() try: return queries.tenant_detail(conn, provider, name, range_days=range) diff --git a/api/queries.py b/api/queries.py index 45ddd88..33f7708 100644 --- a/api/queries.py +++ b/api/queries.py @@ -365,6 +365,239 @@ def services_for_months(conn: sqlite3.Connection, provider: str, name: str, mont return dict(sorted(((k, round(v, 2)) for k, v in totals.items()), key=lambda kv: -kv[1])) +# ---------------------------------------------------------------------- +# Vue globale par service (page "Services") +# ---------------------------------------------------------------------- + +def _last_n_daily_dates(conn: sqlite3.Connection, n: int, provider: str | None = None) -> list[str]: + """Les n dates de collecte journalière les plus récentes, tous tenants + confondus (ou un seul provider si filtré) — aligne la fenêtre sur les + jours réellement collectés plutôt que sur un simple `today - n` (qui + pourrait déborder sur des jours pas encore collectés).""" + where = ["granularity = 'daily'", "status = 'ok'"] + params: list = [] + if provider: + where.append("provider = ?") + params.append(provider) + params.append(n) + + rows = conn.execute( + f""" + SELECT DISTINCT period_start FROM collections + WHERE {' AND '.join(where)} + ORDER BY period_start DESC LIMIT ? + """, + params, + ).fetchall() + return [r["period_start"] for r in rows] + + +def _services_breakdown(totals: dict[str, float], total: float) -> list[dict]: + return sorted( + ( + { + "service_name": name, + "amount": round(amount, 2), + "pct": round(amount / total * 100, 1) if total else 0.0, + } + for name, amount in totals.items() + ), + key=lambda s: -s["amount"], + ) + + +def global_services(conn: sqlite3.Connection, range_days: int = 30, provider: str | None = None) -> dict: + """Répartition des coûts par service, tous tenants/providers confondus + (ou un seul provider si `provider` est fourni). + + range_days=1, 7 ou 30 -> derniers jours réellement collectés (granularité + 'daily'), la plus précise. + range_days=365 -> 12 derniers mois glissants ; même règle de + priorité 'daily' > 'monthly' par (tenant, mois) + que `_merged_monthly`, pour ne pas compter deux + fois un mois couvert par les deux granularités.""" + if range_days in (1, 7, 30): + days = _last_n_daily_dates(conn, range_days, provider=provider) + if not days: + return { + "mode": "daily", "range_days": range_days, "provider": provider, + "period_start": None, "period_end": None, + "total": 0.0, "services": [], + } + + placeholders = ",".join("?" * len(days)) + where = [f"c.period_start IN ({placeholders})"] + params: list = list(days) + if provider: + where.append("c.provider = ?") + params.append(provider) + + rows = conn.execute( + f""" + SELECT sc.service_name AS service_name, SUM(sc.amount) AS amount + FROM service_costs sc + JOIN collections c ON c.id = sc.collection_id + WHERE c.status = 'ok' AND c.granularity = 'daily' AND {' AND '.join(where)} + GROUP BY sc.service_name + """, + params, + ).fetchall() + totals = {r["service_name"]: r["amount"] for r in rows} + total = sum(totals.values()) + + return { + "mode": "daily", + "range_days": range_days, + "provider": provider, + "period_start": min(days), + "period_end": max(days), + "total": round(total, 2), + "services": _services_breakdown(totals, total), + } + + month_keys = _last_n_months(12) + where = ["substr(c.period_start, 1, 7) >= ?"] + params = [month_keys[0]] + if provider: + where.append("c.provider = ?") + params.append(provider) + + rows = conn.execute( + f""" + SELECT c.provider, c.tenant_name, substr(c.period_start, 1, 7) AS month, + c.granularity, sc.service_name AS service_name, SUM(sc.amount) AS amount + FROM service_costs sc + JOIN collections c ON c.id = sc.collection_id + WHERE c.status = 'ok' AND {' AND '.join(where)} + GROUP BY c.provider, c.tenant_name, month, c.granularity, sc.service_name + """, + params, + ).fetchall() + + daily_keys = {(r["provider"], r["tenant_name"], r["month"]) for r in rows if r["granularity"] == "daily"} + totals: dict[str, float] = {} + for r in rows: + if r["granularity"] == "monthly" and (r["provider"], r["tenant_name"], r["month"]) in daily_keys: + continue + totals[r["service_name"]] = totals.get(r["service_name"], 0.0) + r["amount"] + + total = sum(totals.values()) + + return { + "mode": "monthly", + "range_days": None, + "provider": provider, + "months": month_keys, + "total": round(total, 2), + "services": _services_breakdown(totals, total), + } + + +def service_tenants_detail( + conn: sqlite3.Connection, service_name: str, range_days: int = 30, provider: str | None = None +) -> dict: + """Répartition par tenant pour un service donné (clic sur un service de + la page Services), sur la même période/règle daily-vs-monthly que + `global_services`. `provider` restreint aux tenants d'un seul CSP.""" + if range_days in (1, 7, 30): + days = _last_n_daily_dates(conn, range_days, provider=provider) + if not days: + return { + "mode": "daily", "range_days": range_days, "provider": provider, + "period_start": None, "period_end": None, + "service_name": service_name, "total": 0.0, "tenants": [], + } + + placeholders = ",".join("?" * len(days)) + where = [f"c.period_start IN ({placeholders})", "sc.service_name = ?"] + params: list = [*days, service_name] + if provider: + where.append("c.provider = ?") + params.append(provider) + + rows = conn.execute( + f""" + SELECT c.provider, c.tenant_name, SUM(sc.amount) AS amount + FROM service_costs sc + JOIN collections c ON c.id = sc.collection_id + WHERE c.status = 'ok' AND c.granularity = 'daily' AND {' AND '.join(where)} + GROUP BY c.provider, c.tenant_name + """, + params, + ).fetchall() + total = sum(r["amount"] for r in rows) + tenants = sorted( + ( + { + "provider": r["provider"], "tenant_name": r["tenant_name"], + "amount": round(r["amount"], 2), + "pct": round(r["amount"] / total * 100, 1) if total else 0.0, + } + for r in rows + ), + key=lambda t: -t["amount"], + ) + return { + "mode": "daily", + "range_days": range_days, + "provider": provider, + "period_start": min(days), + "period_end": max(days), + "service_name": service_name, + "total": round(total, 2), + "tenants": tenants, + } + + month_keys = _last_n_months(12) + where = ["substr(c.period_start, 1, 7) >= ?", "sc.service_name = ?"] + params = [month_keys[0], service_name] + if provider: + where.append("c.provider = ?") + params.append(provider) + + rows = conn.execute( + f""" + SELECT c.provider, c.tenant_name, substr(c.period_start, 1, 7) AS month, + c.granularity, SUM(sc.amount) AS amount + FROM service_costs sc + JOIN collections c ON c.id = sc.collection_id + WHERE c.status = 'ok' AND {' AND '.join(where)} + GROUP BY c.provider, c.tenant_name, month, c.granularity + """, + params, + ).fetchall() + + daily_keys = {(r["provider"], r["tenant_name"], r["month"]) for r in rows if r["granularity"] == "daily"} + totals: dict[tuple[str, str], float] = {} + for r in rows: + if r["granularity"] == "monthly" and (r["provider"], r["tenant_name"], r["month"]) in daily_keys: + continue + key = (r["provider"], r["tenant_name"]) + totals[key] = totals.get(key, 0.0) + r["amount"] + + total = sum(totals.values()) + tenants = sorted( + ( + { + "provider": tenant_provider, "tenant_name": tenant_name, + "amount": round(amount, 2), + "pct": round(amount / total * 100, 1) if total else 0.0, + } + for (tenant_provider, tenant_name), amount in totals.items() + ), + key=lambda t: -t["amount"], + ) + return { + "mode": "monthly", + "range_days": None, + "provider": provider, + "months": month_keys, + "service_name": service_name, + "total": round(total, 2), + "tenants": tenants, + } + + # ---------------------------------------------------------------------- # Détail par ressource (drawer sur un service) # ---------------------------------------------------------------------- diff --git a/frontend/index.html b/frontend/index.html index 2117172..3f6612b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,6 +9,16 @@ +
+ +
+

simple-cost-dashboard

@@ -46,13 +56,18 @@

Coûts par mois

Total AWS + Azure, EUR

+
+ + + +
- AWS - Azure + AWS + Azure
@@ -70,6 +85,9 @@ + + + diff --git a/frontend/services.html b/frontend/services.html new file mode 100644 index 0000000..c33464c --- /dev/null +++ b/frontend/services.html @@ -0,0 +1,79 @@ + + + + + +Services — simple-cost-dashboard + + + + +
+ +
+ +
+
+

Services

+ coûts par service, tous tenants confondus +
+
+ +
+ +
+ +

Répartition par service

+
+
+
+

30 derniers jours

+

EUR, converti au taux du jour de collecte

+
+
+ + + + +
+
+
+

Clique un service pour voir le détail par tenant.

+
+ + + +
+
+
+
Chargement…
+
+
+ +
+ +
+
+ +
+ + + + + + diff --git a/frontend/static/app.js b/frontend/static/app.js index 57bd835..04611bb 100644 --- a/frontend/static/app.js +++ b/frontend/static/app.js @@ -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 = ` `; diff --git a/frontend/static/common.js b/frontend/static/common.js index 3e8c0b8..89924fb 100644 --- a/frontend/static/common.js +++ b/frontend/static/common.js @@ -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; } diff --git a/frontend/static/services.js b/frontend/static/services.js new file mode 100644 index 0000000..2b4b16b --- /dev/null +++ b/frontend/static/services.js @@ -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 = `
Aucune donnée de coût sur cette période. Lance collect.py ou import_historical.py pour peupler le dashboard.
`; + 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 `${col.label}${sortIndicator(col.key)}`; + }).join(""); + + const rows = sorted + .map( + (s) => ` + + ${escapeHtml(s.service_name)} + ${formatEUR(s.amount)} + ${s.pct.toFixed(1)} % + ` + ) + .join(""); + + wrap.innerHTML = ` + + ${headerCells} + ${rows} + + + + + + + +
Total${formatEUR(currentTotal)}
`; + + 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 = ` + `; + } +} + +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 = `
Chargement…
`; + 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 = + ``; + }); +} + +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 = `
Aucun tenant n'a de coût sur ce service pour cette période.
`; + return; + } + + bodyEl.innerHTML = data.tenants + .map( + (t) => ` +
+
+
+ ${providerLabel(t.provider)} + ${escapeHtml(t.tenant_name)} +
+
${t.pct.toFixed(1)} %
+
+
${formatEUR(t.amount)}
+
` + ) + .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 = ` + `; + document.getElementById("services-table-wrap").innerHTML = ""; + } +} + +init(); diff --git a/frontend/static/style.css b/frontend/static/style.css index 41dc559..9516636 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -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; diff --git a/frontend/static/tenant.js b/frontend/static/tenant.js index 506ef71..042138c 100644 --- a/frontend/static/tenant.js +++ b/frontend/static/tenant.js @@ -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(); diff --git a/frontend/tenant.html b/frontend/tenant.html index 6c2255d..5da3b15 100644 --- a/frontend/tenant.html +++ b/frontend/tenant.html @@ -9,6 +9,16 @@ +
+ +
+

Chargement…

@@ -46,7 +56,8 @@

Coûts journaliers

EUR, converti au taux du jour de collecte

-
+
+ @@ -60,28 +71,28 @@

Répartition par service (30 derniers jours)

-
-
-
-
-

Top services

-

 

-
-
-
- + +
+
+
+

Top services

+

 

-
-
-
-

Détail

-
-
-
-
Chargement…
+
+ +
+
+ +
+
+
+

Détail

+
+
Chargement…
+
@@ -98,6 +109,9 @@
+
+
+