diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a43f953 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +.git/ +.gitignore +*.db +*.log +config.json +config.example.json +historique/ +.env +.DS_Store +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37921d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +*.db +*.log +config.json +data/ +.env +.DS_Store +.claude/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..068e72e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# simple-cost-dashboard +# +# Une seule image sert deux usages, avec la commande par défaut adaptée au +# déploiement Kubernetes envisagé : +# - Deployment "web" : lance l'API + le frontend (uvicorn), commande par défaut +# - CronJob "collect" : même image, commande surchargée : +# command: ["python", "collector/collect.py"] +# args: ["--config", "/config/config.json", "--db", "/data/cost_dashboard.db"] +# +# Ça évite de maintenir deux images distinctes pour deux moitiés de la même +# application. + +FROM python:3.12-slim + +# Dépendances système minimales (certificats TLS pour les appels AWS/Azure/ +# taux de change ; rien de plus n'est nécessaire, pas de compilation requise +# par boto3/azure-identity/fastapi). +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY api/ ./api/ +COPY collector/ ./collector/ +COPY db/ ./db/ +COPY frontend/ ./frontend/ + +# Utilisateur non-root (bonne pratique, souvent imposée par les PodSecurityPolicy / +# PodSecurityStandards en Kubernetes). +RUN useradd --create-home --uid 1000 appuser + +# Répertoire pour la base SQLite : à monter sur un PersistentVolume en k8s +# (sinon les données de collecte sont perdues à chaque redéploiement du pod). +RUN mkdir -p /data && chown -R appuser:appuser /data /app +VOLUME ["/data"] + +ENV COST_DASHBOARD_DB=/data/cost_dashboard.db \ + PYTHONUNBUFFERED=1 + +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +# Commande par défaut : lance le serveur web. Le CronJob de collecte +# surcharge 'command'/'args' au niveau du manifeste Kubernetes (voir plus haut). +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"] \ No newline at end of file diff --git a/LICENSE b/LICENSE index 3051373..1da6893 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 ludal +Copyright (c) 2026 ludovic.cartier@brainsys.io Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including diff --git a/README.md b/README.md index b89383e..3db39c9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,290 @@ # simple-cost-dashboard +Suivi centralisé des coûts AWS et Azure pour une multitude de tenants, +avec conversion automatique en EUR et historique conservé en base SQLite. +Dashboard web (page globale + détail par tenant) alimenté par une collecte +quotidienne (cron). + +--- + +## 1. Installation locale (sans Docker) + +```bash +cd simple-cost-dashboard +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## 2. Configuration des tenants + +Crée un fichier `config.json` à la racine du projet (à côté de ce README) : + +```json +{ + "aws": [ + { "name": "analytics-platform", "profile": "analytics-platform" }, + { + "name": "client-a-prod", + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "VOTRE_SECRET_ICI" + } + ], + "azure": [ + { + "name": "client-c-prod", + "tenant_id": "00000000-0000-0000-0000-000000000000", + "client_id": "11111111-1111-1111-1111-111111111111", + "client_secret": "VOTRE_SECRET_ICI", + "subscription_id": "22222222-2222-2222-2222-222222222222" + } + ] +} +``` + +- **AWS** — deux modes au choix par tenant, dans l'ordre de priorité + `access_key_id`/`secret_access_key` puis `profile` : + - `access_key_id` + `secret_access_key` : clé IAM dédiée au tenant, + limitée au droit `ce:GetCostAndUsage`. **Seul mode qui fonctionne en + conteneur/Kubernetes** — il ne dépend d'aucun fichier ni état sur + l'hôte, contrairement à `profile`. + - `profile` : un profil déjà configuré dans `~/.aws/credentials` ou + `~/.aws/config`, pratique en local. Nécessite de monter `~/.aws` dans + le conteneur (`-v ~/.aws:/home/appuser/.aws:ro`) — et **ne fonctionne + pas avec un profil SSO** en conteneur : le token mis en cache finit + par expirer, et son renouvellement demande un navigateur, indisponible + côté conteneur/CronJob. Réservé au dev local avec des clés statiques + ou un profil SSO valide au moment du run. +- **Azure** : un service principal par tenant (`az ad sp create-for-rbac`), + avec le rôle **Cost Management Reader** sur la subscription visée. +- Le champ `"name"` est l'identifiant utilisé partout (URLs, dashboard). + Deux tenants peuvent avoir le même `name` sur des CSP différents (ex: + `analytics-platform` en AWS **et** en Azure) — ils restent distingués + par le couple `(provider, name)`, y compris dans les URLs + (`/tenant/aws/analytics-platform` vs `/tenant/azure/analytics-platform`). + +⚠️ `config.json` contient des secrets (`client_secret` Azure, `secret_access_key` +AWS). Ne le commite pas — il est déjà exclu via `.gitignore`. + +## 3. Base de données + +La base SQLite (`cost_dashboard.db` par défaut, chemin surchargeable via +`COST_DASHBOARD_DB`) et son schéma sont créés automatiquement au premier +lancement de `collect.py`, `import_historical.py` ou de l'API. Rien à +faire manuellement. Le fichier `db/schema.sql` décrit la structure. + +## 4. Importer l'historique (une seule fois) + +**Depuis AWS/Azure directement**, pour peupler la base avec tout l'historique +disponible sans étape intermédiaire : + +```bash +cd collector +python backfill_historical.py --config ../config.json --db ../cost_dashboard.db +``` + +Rapatrie 12 mois par défaut (`--months` pour ajuster), un par un, pour +chaque tenant de `config.json`. Limites à connaître : +- **AWS Cost Explorer ne conserve que 12 mois glissants** — au-delà, l'API + ne renvoie rien (pas une erreur), donc `--months` plus grand n'aide pas + côté AWS. Azure dépend du contrat (souvent plus long). +- Chaque mois interrogé est un appel Cost Explorer/Cost Management facturé + (~0,01 $ la requête AWS) — pour 28 tenants sur 12 mois, prévoir ~336 + requêtes (quelques dollars, payés une fois). +- `--tenant ` pour ne rapatrier qu'un seul tenant (ex: nouvellement + ajouté à `config.json`). + +En plus de l'historique mensuel, ce même script rapatrie aussi les +**30 derniers jours en granularité journalière** (`--daily-days`, 0 pour +désactiver) — sinon les graphes 7j/30j de la page tenant restent vides +jusqu'à ce que le cron ait tourné 7 à 30 jours de suite pour les remplir. +Rejoue exactement `collect_tenant`/`collect_tenant_resources` de +`collect.py` (mêmes fonctions que le cron), jour par jour : même ordre de +grandeur en appels facturés que le backfill mensuel (~840 requêtes pour +28 tenants sur 30 jours), et ça prend plusieurs minutes (appels +séquentiels). Le cron quotidien écrase ensuite ces jours au fil de l'eau +sans créer de doublons (upsert sur la même clé). + +⚠️ **Côté Azure**, l'API Cost Management est plus vite sujette au +rate-limiting (429) que Cost Explorer côté AWS — surtout en rafale sur les +dizaines d'appels d'un backfill. `providers.py` retente automatiquement les +429 (jusqu'à 8 fois), en respectant le délai suggéré par Azure — pas le +`Retry-After` HTTP standard, mais ses propres en-têtes +`x-ms-ratelimit-microsoft.costmanagement-{entity,tenant,clienttype}-retry-after`, +le plus restrictif faisant foi quand plusieurs sont présents. `backfill_historical.py` +ajoute en plus une pause de 2s entre deux appels Azure pour limiter le +risque d'en déclencher. Si un tenant Azure échoue quand même après ça, +relancer le backfill juste sur lui (`--tenant `) suffit généralement. + +**Depuis des exports JSON existants** (si tu as déjà des données de coûts +passées, exportées via `cost_report.py --json` ou `tenant_detail.py --json`, +par exemple générées avant la mise en place de ce dashboard) : + +```bash +python import_historical.py --db ../cost_dashboard.db ../historique/*.json +``` + +Les deux stockent avec `granularity='monthly'`, pour ne pas se mélanger avec +la collecte journalière (qui prime dessus dans les agrégations dès qu'elle a +des données pour un mois donné). Les deux sont ré-exécutables sans créer de +doublons. + +## 5. Collecte quotidienne (cron) + +`collect.py` récupère le coût de **J-1** (les données du jour même sont +rarement finalisées côté AWS/Azure) pour chaque tenant, le convertit en +EUR (taux figé au moment de la collecte), et l'écrit en base avec +`granularity='daily'`. + +**Test manuel :** +```bash +cd collector +python collect.py --config ../config.json --db ../cost_dashboard.db +``` + +**Rattraper une journée précise :** +```bash +python collect.py --config ../config.json --db ../cost_dashboard.db --date 2026-07-28 +``` + +Un échec sur un tenant (token expiré, permissions...) n'empêche pas la +collecte des autres — journalisé en base (`status='error'`) et affiché +dans le dashboard plutôt que de laisser un trou silencieux. + +Juste après, pour chaque tenant collecté avec succès, `collect.py` récupère +aussi le **détail par ressource** (bucket S3, instance EC2, VM Azure...) sur +une fenêtre glissante de **14 jours** — c'est le maximum que fournit AWS Cost +Explorer pour ce niveau de détail (limite dure de l'API, pas de notre code), +gardé identique côté Azure par cohérence. Alimente le panneau qui s'ouvre au +clic sur un service dans le dashboard (§6). Côté AWS, ça suppose d'avoir +activé **"Resource IDs"** dans les préférences Cost Explorer du compte — si +ce n'est pas fait, la collecte du détail échoue proprement pour ce tenant +(`resource_collection_status.status = 'unavailable'`) sans bloquer le reste, +et le dashboard l'affiche avec un message explicite plutôt qu'un vide. + +**En dehors de Docker/Kubernetes**, installation en crontab classique : +``` +0 6 * * * cd /opt/simple-cost-dashboard/collector && /opt/simple-cost-dashboard/venv/bin/python collect.py --config ../config.json --db ../cost_dashboard.db >> ../cron.log 2>&1 +``` +(Voir §7 pour l'équivalent en CronJob Kubernetes.) + +## 6. Lancer le dashboard web + +```bash +cd api +uvicorn main:app --reload --port 8000 +``` + +Puis ouvre `http://localhost:8000`. + +- `/` — vue globale : tous les tenants, coûts mensuels sur 12 mois glissants +- `/tenant//` — détail d'un tenant : graph 7j/30j avec + comparaison à la période précédente, répartition par service (30 + derniers jours), total depuis le 1er janvier. Cliquer sur un service (dans + le graphe ou le tableau) ouvre un panneau avec le détail par ressource + (bucket, instance, VM...) sur les 14 derniers jours — voir §5 pour les + limites côté AWS (fenêtre 14j, "Resource IDs" à activer). +- `/health` — endpoint de liveness/readiness (utilisé par Kubernetes) + +## 7. Docker + +**Build :** +```bash +docker build -t simple-cost-dashboard:latest . +``` + +**Lancer le dashboard web :** +```bash +docker run -d \ + --name cost-dashboard \ + -p 8000:8000 \ + -v $(pwd)/data:/data \ + -v $(pwd)/config.json:/app/config.json:ro \ + simple-cost-dashboard:latest +``` +La base SQLite vit dans `/data` (volume monté) pour survivre aux +redémarrages/recréations du container. + +**Lancer une collecte ponctuelle (même image, commande différente) :** +```bash +docker run --rm \ + -v $(pwd)/data:/data \ + -v $(pwd)/config.json:/app/config.json:ro \ + simple-cost-dashboard:latest \ + python collector/collect.py --config /app/config.json --db /data/cost_dashboard.db +``` + +C'est la **même image** pour le serveur web et pour la collecte — seule la +commande change. C'est le pattern que je reprendrai pour Kubernetes : +un `Deployment` (serveur web, commande par défaut de l'image) et un +`CronJob` (collecte, commande surchargée), tous deux basés sur la même +image `simple-cost-dashboard:latest`. + +## 8. Structure du projet + +``` +simple-cost-dashboard/ +├── Dockerfile +├── .dockerignore +├── requirements.txt +├── config.json # à créer (voir §2) — non versionné +├── cost_dashboard.db # créé automatiquement (local) / dans /data (Docker) +├── db/ +│ └── schema.sql # schéma de la base +├── collector/ +│ ├── providers.py # accès AWS Cost Explorer / Azure Cost Mgmt + conversion EUR +│ ├── db.py # connexion SQLite + écriture des relevés +│ ├── collect.py # cron quotidien (granularité journalière + détail ressources 14j) +│ ├── backfill_historical.py # rapatrie l'historique dispo depuis AWS/Azure (voir §4) +│ ├── import_historical.py # import ponctuel d'exports JSON déjà générés +│ ├── cost_report.py # CLI ponctuel : rapport multi-tenants +│ └── tenant_detail.py # CLI ponctuel : détail d'un tenant +├── api/ +│ ├── main.py # app FastAPI (routes API + pages statiques + /health) +│ └── queries.py # agrégations (vue mensuelle, YTD, comparaison, services, ressources) +├── frontend/ +│ ├── index.html # page globale +│ ├── tenant.html # page détail tenant +│ └── static/ +│ ├── style.css +│ ├── common.js # utilitaires partagés (formatage, config Chart.js) +│ ├── app.js # logique page globale +│ └── 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 new file mode 100644 index 0000000..782c859 --- /dev/null +++ b/api/main.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +main.py — App FastAPI : pages statiques (page globale + détail tenant), +API JSON pour les graphiques, et /health pour Kubernetes. +""" + +from __future__ import annotations + +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles + +import queries + +FRONTEND_DIR = Path(__file__).parent.parent / "frontend" +PROVIDERS = ("aws", "azure") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + queries.init_db() + yield + + +app = FastAPI(title="simple-cost-dashboard", lifespan=lifespan) +app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR / "static")), name="static") + + +def _read_page(filename: str) -> str: + return (FRONTEND_DIR / filename).read_text() + + +def _require_provider(provider: str) -> None: + if provider not in PROVIDERS: + raise HTTPException(status_code=404, detail=f"Provider inconnu : {provider}") + + +@app.get("/", response_class=HTMLResponse) +def index_page(): + return _read_page("index.html") + + +@app.get("/tenant/{provider}/{name}", response_class=HTMLResponse) +def tenant_page(provider: str, name: str): + _require_provider(provider) + return _read_page("tenant.html") + + +@app.get("/health") +def health(): + try: + conn = queries.get_connection() + try: + conn.execute("SELECT 1") + finally: + conn.close() + except Exception as e: + raise HTTPException(status_code=503, detail=f"Base indisponible : {e}") + return {"status": "ok"} + + +@app.get("/api/overview") +def api_overview(months: int = Query(12, ge=1, le=24)): + conn = queries.get_connection() + try: + return queries.overview(conn, months=months) + 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") + conn = queries.get_connection() + try: + return queries.tenant_detail(conn, provider, name, range_days=range) + finally: + conn.close() + + +@app.get("/api/tenant/{provider}/{name}/services") +def api_tenant_services_selection( + provider: str, name: str, + days: str = Query(None), months: str = Query(None), +): + _require_provider(provider) + conn = queries.get_connection() + try: + if months: + selected = [m for m in months.split(",") if m] + return {"services": queries.services_for_months(conn, provider, name, selected)} + if days: + selected = [d for d in days.split(",") if d] + return {"services": queries.services_for_days(conn, provider, name, selected)} + raise HTTPException(status_code=400, detail="'days' ou 'months' requis") + finally: + conn.close() + + +@app.get("/api/tenant/{provider}/{name}/resources/{service_name}") +def api_resource_detail(provider: str, name: str, service_name: str): + _require_provider(provider) + conn = queries.get_connection() + try: + return queries.resource_detail(conn, provider, name, service_name) + finally: + conn.close() diff --git a/api/queries.py b/api/queries.py new file mode 100644 index 0000000..45ddd88 --- /dev/null +++ b/api/queries.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +""" +queries.py — Agrégations lues depuis la base SQLite pour l'API FastAPI. + +Lecture seule : ce module n'a jamais besoin de config.json (qui contient les +secrets Azure) — il ne connaît que la base, alimentée par collector/collect.py. +""" + +from __future__ import annotations + +import os +import sqlite3 +from datetime import date +from pathlib import Path + +DEFAULT_DB_PATH = "cost_dashboard.db" +SCHEMA_PATH = Path(__file__).parent.parent / "db" / "schema.sql" + + +def _resolve_db_path(db_path: str | None = None) -> str: + return db_path or os.environ.get("COST_DASHBOARD_DB") or DEFAULT_DB_PATH + + +def init_db(db_path: str | None = None) -> None: + """Crée le fichier SQLite et applique le schéma s'il n'existe pas déjà. + Idempotent — appelé au démarrage de l'API pour que le dashboard reste + utilisable (vide) même avant la première collecte.""" + path = _resolve_db_path(db_path) + Path(path).parent.mkdir(parents=True, exist_ok=True) + + conn = sqlite3.connect(path) + try: + conn.executescript(SCHEMA_PATH.read_text()) + conn.commit() + finally: + conn.close() + + +def get_connection(db_path: str | None = None) -> sqlite3.Connection: + path = _resolve_db_path(db_path) + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + return conn + + +# ---------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------- + +def _last_n_months(n: int, today: date | None = None) -> list[str]: + """Les n derniers mois (format 'YYYY-MM'), du plus ancien au plus récent, + mois courant inclus.""" + today = today or date.today() + y, m = today.year, today.month + months = [] + for _ in range(n): + months.append(f"{y:04d}-{m:02d}") + m -= 1 + if m == 0: + m, y = 12, y - 1 + return list(reversed(months)) + + +def _merged_monthly( + conn: sqlite3.Connection, + since_month: str, + provider: str | None = None, + tenant_name: str | None = None, +) -> dict[tuple[str, str, str], float]: + """Total par (provider, tenant, mois) depuis `since_month` (inclus, + format 'YYYY-MM'). Quand un mois a à la fois des relevés 'daily' et + 'monthly' (import historique), les 'daily' priment — ils sont plus + précis et évitent le double comptage.""" + where = ["status = 'ok'", "period_start >= ?"] + params: list[str] = [f"{since_month}-01"] + if provider: + where.append("provider = ?") + params.append(provider) + if tenant_name: + where.append("tenant_name = ?") + params.append(tenant_name) + + rows = conn.execute( + f""" + SELECT provider, tenant_name, substr(period_start, 1, 7) AS month, + granularity, SUM(total_amount) AS amount + FROM collections + WHERE {' AND '.join(where)} + GROUP BY provider, tenant_name, month, granularity + """, + params, + ).fetchall() + + merged: dict[tuple[str, str, str], float] = {} + have_daily: set[tuple[str, str, str]] = set() + for r in rows: + if r["granularity"] == "daily": + key = (r["provider"], r["tenant_name"], r["month"]) + merged[key] = r["amount"] + have_daily.add(key) + for r in rows: + if r["granularity"] == "monthly": + key = (r["provider"], r["tenant_name"], r["month"]) + if key not in have_daily: + merged[key] = r["amount"] + return merged + + +def _latest_daily_status(conn: sqlite3.Connection) -> dict[tuple[str, str], dict]: + """Dernier relevé journalier (le plus récent period_start) par tenant, + quel que soit son statut — pour détecter les échecs de collecte.""" + rows = conn.execute( + """ + SELECT provider, tenant_name, status, error_message, collected_at, period_start + FROM collections + WHERE granularity = 'daily' + ORDER BY period_start ASC + """ + ).fetchall() + latest: dict[tuple[str, str], dict] = {} + for r in rows: + latest[(r["provider"], r["tenant_name"])] = dict(r) + return latest + + +def list_tenants(conn: sqlite3.Connection) -> list[dict]: + rows = conn.execute( + "SELECT DISTINCT provider, tenant_name AS name FROM collections ORDER BY provider, tenant_name" + ).fetchall() + return [dict(r) for r in rows] + + +# ---------------------------------------------------------------------- +# Vue globale +# ---------------------------------------------------------------------- + +def overview(conn: sqlite3.Connection, months: int = 12) -> dict: + month_keys = _last_n_months(months) + merged = _merged_monthly(conn, month_keys[0]) + latest_status = _latest_daily_status(conn) + + current_month_key = month_keys[-1] + previous_month_key = month_keys[-2] if len(month_keys) >= 2 else None + + providers_totals = {"aws": [0.0] * months, "azure": [0.0] * months} + tenants = [] + + for t in list_tenants(conn): + provider, name = t["provider"], t["name"] + + monthly = [] + for i, mk in enumerate(month_keys): + amount = merged.get((provider, name, mk)) + monthly.append(round(amount, 2) if amount is not None else 0.0) + if amount is not None: + providers_totals[provider][i] += amount + + current = merged.get((provider, name, current_month_key), 0.0) + previous = merged.get((provider, name, previous_month_key), 0.0) if previous_month_key else 0.0 + delta_pct = ((current - previous) / previous * 100) if previous else None + + status_info = latest_status.get((provider, name)) + + tenants.append({ + "provider": provider, + "name": name, + "current_month": round(current, 2), + "previous_month": round(previous, 2), + "delta_pct": round(delta_pct, 1) if delta_pct is not None else None, + "monthly": monthly, + "status": status_info["status"] if status_info else None, + "error_message": status_info["error_message"] if status_info else None, + "last_collected_at": status_info["collected_at"] if status_info else None, + }) + + tenants.sort(key=lambda t: t["current_month"], reverse=True) + tenants_error = [t for t in tenants if t["status"] == "error"] + + return { + "months": month_keys, + "providers_totals": {k: [round(v, 2) for v in vals] for k, vals in providers_totals.items()}, + "tenants": tenants, + "kpis": { + "total_current_month": round(sum(t["current_month"] for t in tenants), 2), + "total_aws_current_month": round(providers_totals["aws"][-1], 2), + "total_azure_current_month": round(providers_totals["azure"][-1], 2), + "tenants_error": [{"provider": t["provider"], "name": t["name"]} for t in tenants_error], + }, + "currency": "EUR", + } + + +# ---------------------------------------------------------------------- +# Détail tenant +# ---------------------------------------------------------------------- + +def tenant_detail(conn: sqlite3.Connection, provider: str, name: str, range_days: int = 30) -> dict: + latest_status = _latest_daily_status(conn).get((provider, name)) + + daily_rows = conn.execute( + """ + SELECT period_start, total_amount, currency + FROM collections + WHERE provider = ? AND tenant_name = ? AND granularity = 'daily' AND status = 'ok' + ORDER BY period_start ASC + """, + (provider, name), + ).fetchall() + + has_monthly_data = False + + if range_days == 365: + # Vue "1 an" : par mois, pas par jour — la collecte quotidienne ne + # remonte pas assez loin pour ça, l'historique au-delà vient du + # backfill/import (granularity='monthly'). Pas de comparaison à la + # période précédente : l'historique dispo dépasse rarement 12 mois. + month_keys = _last_n_months(12) + merged = _merged_monthly(conn, month_keys[0], provider=provider, tenant_name=name) + amounts = [merged.get((provider, name, mk), 0.0) for mk in month_keys] + has_monthly_data = any(amounts) + + period = { + "mode": "monthly", + "range_days": None, + "months": month_keys, + "amounts": [round(a, 2) for a in amounts], + "current_total": round(sum(amounts), 2), + "previous_total": None, + "delta_pct": None, + "has_previous": False, + } + else: + current_rows = daily_rows[-range_days:] if range_days else [] + previous_rows = ( + daily_rows[-2 * range_days:-range_days] + if range_days and len(daily_rows) > range_days + else [] + ) + + current_total = sum(r["total_amount"] for r in current_rows) + previous_total = sum(r["total_amount"] for r in previous_rows) + delta_pct = ((current_total - previous_total) / previous_total * 100) if previous_total else None + + period = { + "mode": "daily", + "range_days": range_days, + "days": [r["period_start"] for r in current_rows], + "amounts": [round(r["total_amount"], 2) for r in current_rows], + "current_total": round(current_total, 2), + "previous_total": round(previous_total, 2), + "delta_pct": round(delta_pct, 1) if delta_pct is not None else None, + "has_previous": len(previous_rows) > 0, + } + + currency = daily_rows[-1]["currency"] if daily_rows else None + if currency is None: + row = conn.execute( + """ + SELECT currency FROM collections + WHERE provider = ? AND tenant_name = ? AND status = 'ok' + ORDER BY period_start DESC LIMIT 1 + """, + (provider, name), + ).fetchone() + currency = row["currency"] if row else "EUR" + + services: dict[str, float] = {} + service_window = daily_rows[-30:] + if service_window: + start_bound = service_window[0]["period_start"] + rows = conn.execute( + """ + 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.provider = ? AND c.tenant_name = ? AND c.granularity = 'daily' + AND c.status = 'ok' AND c.period_start >= ? + GROUP BY sc.service_name + ORDER BY amount DESC + """, + (provider, name, start_bound), + ).fetchall() + services = {r["service_name"]: round(r["amount"], 2) for r in rows} + + since_year = f"{date.today().year}-01" + ytd_merged = _merged_monthly(conn, since_year, provider=provider, tenant_name=name) + ytd_total = round(sum(ytd_merged.values()), 2) + + return { + "tenant": {"provider": provider, "name": name}, + "currency": currency, + "status": latest_status, + "period": period, + "services": services, + "ytd_total": ytd_total, + "has_data": bool(daily_rows), + "has_monthly_data": has_monthly_data, + } + + +def services_for_days(conn: sqlite3.Connection, provider: str, name: str, days: list[str]) -> dict[str, float]: + """Répartition par service sur un sous-ensemble arbitraire de jours + (sélection de barres sur le graphe journalier, mode 7j/30j).""" + if not days: + return {} + placeholders = ",".join("?" * len(days)) + 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.provider = ? AND c.tenant_name = ? AND c.granularity = 'daily' AND c.status = 'ok' + AND c.period_start IN ({placeholders}) + GROUP BY sc.service_name + """, + (provider, name, *days), + ).fetchall() + return dict(sorted(((r["service_name"], round(r["amount"], 2)) for r in rows), key=lambda kv: -kv[1])) + + +def services_for_months(conn: sqlite3.Connection, provider: str, name: str, months: list[str]) -> dict[str, float]: + """Répartition par service sur un sous-ensemble arbitraire de mois + (sélection de barres sur le graphe "1 an"). Comme _merged_monthly, + préfère la granularité 'daily' à 'monthly' pour les mois où les deux + existent, pour ne pas rater un mois couvert par la collecte quotidienne + mais jamais backfillé.""" + if not months: + return {} + placeholders = ",".join("?" * len(months)) + + daily_rows = conn.execute( + f""" + SELECT substr(c.period_start, 1, 7) AS month, 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.provider = ? AND c.tenant_name = ? AND c.granularity = 'daily' AND c.status = 'ok' + AND substr(c.period_start, 1, 7) IN ({placeholders}) + GROUP BY month, sc.service_name + """, + (provider, name, *months), + ).fetchall() + + months_with_daily = {r["month"] for r in daily_rows} + remaining_months = [m for m in months if m not in months_with_daily] + + monthly_rows = [] + if remaining_months: + placeholders2 = ",".join("?" * len(remaining_months)) + monthly_rows = conn.execute( + f""" + SELECT substr(c.period_start, 1, 7) AS month, 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.provider = ? AND c.tenant_name = ? AND c.granularity = 'monthly' AND c.status = 'ok' + AND substr(c.period_start, 1, 7) IN ({placeholders2}) + GROUP BY month, sc.service_name + """, + (provider, name, *remaining_months), + ).fetchall() + + totals: dict[str, float] = {} + for r in list(daily_rows) + list(monthly_rows): + totals[r["service_name"]] = totals.get(r["service_name"], 0.0) + r["amount"] + + return dict(sorted(((k, round(v, 2)) for k, v in totals.items()), key=lambda kv: -kv[1])) + + +# ---------------------------------------------------------------------- +# Détail par ressource (drawer sur un service) +# ---------------------------------------------------------------------- + +def resource_detail(conn: sqlite3.Connection, provider: str, name: str, service_name: str) -> dict: + """Détail par ressource pour un service donné d'un tenant — alimenté par + collect_tenant_resources (collector/collect.py), cliché glissant sur + RESOURCE_WINDOW_DAYS jours. `status` distingue 'ok' (des ressources sont + listées), 'unavailable' (ex: Resource IDs non activé côté AWS), + 'error' et 'never_collected' (pas encore de run de collecte depuis la + mise à jour du schéma).""" + status_row = conn.execute( + "SELECT status, message, collected_at FROM resource_collection_status WHERE provider = ? AND tenant_name = ?", + (provider, name), + ).fetchone() + + if status_row is None: + return { + "status": "never_collected", + "message": None, + "collected_at": None, + "period_start": None, + "period_end": None, + "resources": [], + } + + resources = [] + period_start = period_end = None + if status_row["status"] == "ok": + bounds = conn.execute( + "SELECT period_start, period_end FROM resource_costs WHERE provider = ? AND tenant_name = ? LIMIT 1", + (provider, name), + ).fetchone() + if bounds: + period_start, period_end = bounds["period_start"], bounds["period_end"] + + rows = conn.execute( + """ + SELECT resource_id, resource_name, amount + FROM resource_costs + WHERE provider = ? AND tenant_name = ? AND service_name = ? + ORDER BY amount DESC + """, + (provider, name, service_name), + ).fetchall() + resources = [ + {"resource_id": r["resource_id"], "resource_name": r["resource_name"], "amount": round(r["amount"], 2)} + for r in rows + ] + + return { + "status": status_row["status"], + "message": status_row["message"], + "collected_at": status_row["collected_at"], + "period_start": period_start, + "period_end": period_end, + "resources": resources, + } diff --git a/collector/backfill_historical.py b/collector/backfill_historical.py new file mode 100644 index 0000000..2de2e28 --- /dev/null +++ b/collector/backfill_historical.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +""" +backfill_historical.py — Rapatrie l'historique de coûts disponible +directement depuis AWS Cost Explorer / Azure Cost Management vers la base, +mois par mois, sans passer par un export JSON intermédiaire (contrairement +à import_historical.py, qui importe des fichiers déjà exportés). + +À lancer une fois à la mise en place du dashboard (ou pour rattraper +l'historique d'un tenant nouvellement ajouté). + +En plus de l'historique mensuel, rapatrie aussi les `--daily-days` derniers +jours en granularité journalière ('daily') — sans ça, les graphes 7j/30j de +la page tenant restent vides jusqu'à ce que le cron ait tourné assez de +jours de suite pour les remplir. Réutilise telles quelles collect_tenant et +collect_tenant_resources (collect.py) : mêmes fonctions que celles que le +cron quotidien appelle déjà, juste rejouées sur les jours passés au lieu +d'attendre qu'ils s'accumulent. + +Limites : +- AWS Cost Explorer ne conserve que 12 mois d'historique glissants — au-delà, + l'API ne renvoie simplement rien (pas une erreur). --months au-delà de 12 + ne rapatriera donc rien de plus côté AWS. +- Azure dépend du type de contrat (souvent plus long que 12 mois) — la + limite AWS ne s'applique pas. +- Chaque mois ou jour interrogé est un appel Cost Explorer / Cost Management + facturé (~0,01 $ par requête AWS) : pour N tenants, M mois et D jours, + prévoir environ N x (M + D) requêtes. Pour 28 tenants AWS sur 12 mois + + 30 jours, ça fait ~336 + ~840 requêtes (~12 $ au total), payé une fois — + et ça prend plusieurs minutes (les appels sont séquentiels, un par un). +- Les mois/jours sans coût (compte pas encore créé, par exemple) sont + ignorés plutôt que d'écrire une ligne à 0. + +Stocké avec granularity='monthly' (historique) et 'daily' (30 derniers +jours) — la collecte quotidienne du cron écrase ensuite les jours récents +au fil de l'eau, sans créer de doublons (upsert sur la même clé). + +Utilisation : + python backfill_historical.py --config ../config.json --db ../cost_dashboard.db + python backfill_historical.py --config ../config.json --db ../cost_dashboard.db --months 6 --daily-days 14 + python backfill_historical.py --config ../config.json --db ../cost_dashboard.db --daily-days 0 # historique seul + python backfill_historical.py --config ../config.json --db ../cost_dashboard.db --tenant analytics-platform +""" + +import argparse +import json +import logging +import sys +import time +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from db import get_connection, init_db, upsert_collection +from providers import get_aws_cost, get_azure_cost, convert_result +from collect import collect_tenant, collect_tenant_resources, RESOURCE_WINDOW_DAYS + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +log = logging.getLogger("backfill") + +TARGET_CURRENCY = "EUR" + +# Pause volontaire entre deux appels Cost Management, uniquement côté Azure : +# son quota est plus bas et souvent partagé au niveau du tenant Azure AD +# entier (contrairement à Cost Explorer côté AWS, déjà retenté automatiquement +# par botocore). Mieux vaut ralentir nous-mêmes que de déclencher des 429 en +# rafale sur des dizaines d'appels consécutifs — le retry dans providers.py +# absorbe les 429 occasionnels, mais pas un rythme d'appels trop soutenu. +AZURE_CALL_DELAY_SECONDS = 2.0 + + +def month_ranges(n_months: int) -> list[tuple[date, date]]: + """Les n derniers mois (le plus ancien en premier), en (start, end) + avec end exclusif — mois courant (en cours) inclus.""" + today = date.today() + y, m = today.year, today.month + months = [] + for _ in range(n_months): + start = date(y, m, 1) + end_y, end_m = (y + 1, 1) if m == 12 else (y, m + 1) + end = date(end_y, end_m, 1) + months.append((start, end)) + m -= 1 + if m == 0: + m, y = 12, y - 1 + return list(reversed(months)) + + +def backfill_tenant(conn, provider: str, entry: dict, months: list[tuple[date, date]]) -> None: + name = entry["name"] + for start, end in months: + collected_at = datetime.now(timezone.utc).isoformat() + month_label = start.isoformat()[:7] + + try: + if provider == "aws": + data = get_aws_cost(entry, start.isoformat(), end.isoformat(), granularity="MONTHLY") + else: + data = get_azure_cost( + entry["tenant_id"], entry["client_id"], entry["client_secret"], + entry["subscription_id"], start.isoformat(), end.isoformat(), + ) + + if data["total"] == 0 and not data.get("by_service"): + log.info("VIDE %-6s %-30s %s (aucune donnée)", provider, name, month_label) + continue + + data = convert_result(data, TARGET_CURRENCY) + upsert_collection( + conn, provider, name, "monthly", start.isoformat(), end.isoformat(), + collected_at, "ok", data=data, + ) + log.info("OK %-6s %-30s %s %.2f %s", provider, name, month_label, data["total"], data["currency"]) + + except Exception as e: + log.error("ECHEC %-6s %-30s %s : %s", provider, name, month_label, e) + + finally: + if provider == "azure": + time.sleep(AZURE_CALL_DELAY_SECONDS) + + +def day_ranges(n_days: int) -> list[date]: + """Les n derniers jours (le plus ancien en premier), jusqu'à hier inclus + — même convention que collect.py : le jour même est rarement finalisé + côté AWS/Azure, donc jamais backfillé.""" + yesterday = date.today() - timedelta(days=1) + return [yesterday - timedelta(days=i) for i in range(n_days - 1, -1, -1)] + + +def backfill_tenant_daily(conn, provider: str, entry: dict, days: list[date]) -> None: + """Rejoue collect_tenant (collect.py) sur chaque jour de `days`, puis + collect_tenant_resources une fois si au moins un jour a réussi — exactement + ce que ferait le cron quotidien, jour après jour, mais en une passe.""" + if not days: + return + + any_ok = False + for day in days: + ok = collect_tenant(conn, provider, entry, day.isoformat(), (day + timedelta(days=1)).isoformat()) + any_ok = any_ok or ok + if provider == "azure": + time.sleep(AZURE_CALL_DELAY_SECONDS) + + if any_ok: + resource_end = days[-1] + timedelta(days=1) + resource_start = resource_end - timedelta(days=RESOURCE_WINDOW_DAYS) + collect_tenant_resources(conn, provider, entry, resource_start.isoformat(), resource_end.isoformat()) + + +def main(): + parser = argparse.ArgumentParser(description="Backfill de l'historique de coûts depuis AWS/Azure") + parser.add_argument("--config", default="config.json") + parser.add_argument("--db", default=None, help="Chemin vers la base SQLite") + parser.add_argument("--months", type=int, default=12, + help="Nombre de mois à rapatrier, mois courant inclus (défaut: 12, plafond utile côté AWS ; " + "0 pour ne faire que le backfill journalier)") + parser.add_argument("--daily-days", type=int, default=30, + help="Nombre de jours à rapatrier en granularité journalière, pour que les graphes " + "7j/30j soient déjà peuplés (défaut: 30 ; 0 pour ne faire que l'historique mensuel)") + parser.add_argument("--tenant", help="Ne rapatrier qu'un seul tenant (son 'name' dans config.json)") + args = parser.parse_args() + + db_path = args.db or None + if db_path: + init_db(db_path) + conn = get_connection(db_path) + else: + init_db() + conn = get_connection() + + with open(args.config) as f: + config = json.load(f) + + months = month_ranges(args.months) if args.months > 0 else [] + days = day_ranges(args.daily_days) if args.daily_days > 0 else [] + + if months: + log.info("Backfill de %d mois (%s -> %s)", len(months), months[0][0].isoformat(), months[-1][1].isoformat()) + if days: + log.info("Backfill journalier de %d jours (%s -> %s)", len(days), days[0].isoformat(), days[-1].isoformat()) + + for provider, entries in (("aws", config.get("aws", [])), ("azure", config.get("azure", []))): + for entry in entries: + if args.tenant and entry["name"] != args.tenant: + continue + backfill_tenant(conn, provider, entry, months) + backfill_tenant_daily(conn, provider, entry, days) + + conn.close() + log.info("Terminé.") + + +if __name__ == "__main__": + main() diff --git a/collector/collect.py b/collector/collect.py new file mode 100644 index 0000000..f89e0d8 --- /dev/null +++ b/collector/collect.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +collect.py — Point d'entrée du cron. Collecte le coût de la veille (J-1) +pour chaque tenant défini dans config.json, et l'écrit dans SQLite. + +Pourquoi J-1 et pas "aujourd'hui" : les données de facturation AWS/Azure du +jour même sont généralement incomplètes (délai de consolidation de +quelques heures à ~24-48h côté AWS Cost Explorer notamment). Collecter J-1 +donne un chiffre stable. + +Utilisation (cron quotidien, par exemple à 6h du matin) : + python collect.py --config /path/to/config.json --db /path/to/cost_dashboard.db + +Un échec sur un tenant n'empêche pas la collecte des autres : chaque +tenant est traité indépendamment et les erreurs sont journalisées en base +(status='error') pour que le dashboard puisse l'afficher plutôt que de +silencieusement afficher un trou ou un zéro. +""" + +import argparse +import json +import logging +import sys +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from db import ( + get_connection, init_db, upsert_collection, + upsert_resource_costs, set_resource_collection_status, +) +from providers import ( + get_aws_cost, get_azure_cost, convert_result, + get_aws_resource_costs, get_azure_resource_costs, convert_resource_costs, + ResourceLevelUnavailable, +) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +log = logging.getLogger("collect") + +TARGET_CURRENCY = "EUR" + +# Fenêtre du détail par ressource (bucket S3, instance EC2...) : bornée à 14 +# jours côté AWS Cost Explorer (limite dure de l'API) ; gardée identique côté +# Azure pour rester cohérent entre providers. Voir providers.py. +RESOURCE_WINDOW_DAYS = 14 + + +def collect_tenant(conn, provider: str, entry: dict, period_start: str, period_end: str) -> bool: + """Collecte le relevé quotidien (par service) d'un tenant. Retourne True + si la collecte a réussi — sert à décider si ça vaut la peine de tenter + le détail par ressource juste après (inutile si l'auth du tenant est + déjà cassée).""" + name = entry["name"] + collected_at = datetime.now(timezone.utc).isoformat() + + try: + if provider == "aws": + data = get_aws_cost(entry, period_start, period_end, granularity="DAILY") + else: + data = get_azure_cost( + entry["tenant_id"], entry["client_id"], entry["client_secret"], + entry["subscription_id"], period_start, period_end, + ) + data = convert_result(data, TARGET_CURRENCY) + + upsert_collection( + conn, provider, name, "daily", period_start, period_end, + collected_at, "ok", data=data, + ) + log.info("OK %-6s %-30s %.2f %s", provider, name, data["total"], data["currency"]) + return True + + except Exception as e: + upsert_collection( + conn, provider, name, "daily", period_start, period_end, + collected_at, "error", error_message=str(e), + ) + log.error("ECHEC %-6s %-30s %s", provider, name, e) + return False + + +def collect_tenant_resources(conn, provider: str, entry: dict, period_start: str, period_end: str) -> None: + """Collecte le détail par ressource (RESOURCE_WINDOW_DAYS derniers + jours) d'un tenant, pour le panneau de détail du dashboard. Séparé de + collect_tenant : ça peut échouer pour une raison indépendante (ex: + "Resource IDs" non activé côté AWS) sans que la collecte quotidienne + normale soit affectée.""" + name = entry["name"] + collected_at = datetime.now(timezone.utc).isoformat() + + try: + if provider == "aws": + data = get_aws_resource_costs(entry, period_start, period_end) + else: + data = get_azure_resource_costs( + entry["tenant_id"], entry["client_id"], entry["client_secret"], + entry["subscription_id"], period_start, period_end, + ) + data = convert_resource_costs(data, TARGET_CURRENCY) + + upsert_resource_costs(conn, provider, name, period_start, period_end, data["services"]) + set_resource_collection_status(conn, provider, name, "ok", collected_at) + n_resources = sum(len(v) for v in data["services"].values()) + log.info("OK %-6s %-30s détail ressources (%d ressources)", provider, name, n_resources) + + except ResourceLevelUnavailable as e: + set_resource_collection_status(conn, provider, name, "unavailable", collected_at, message=str(e)) + log.warning("INDISPO %-6s %-30s détail ressources indisponible : %s", provider, name, e) + + except Exception as e: + set_resource_collection_status(conn, provider, name, "error", collected_at, message=str(e)) + log.error("ECHEC %-6s %-30s détail ressources : %s", provider, name, e) + + +def main(): + parser = argparse.ArgumentParser(description="Collecte journalière des coûts (cron)") + parser.add_argument("--config", default="config.json") + parser.add_argument("--db", default=None, help="Chemin vers la base SQLite") + parser.add_argument("--date", help="Jour à collecter (YYYY-MM-DD), défaut: hier") + args = parser.parse_args() + + if args.date: + target_day = date.fromisoformat(args.date) + else: + target_day = date.today() - timedelta(days=1) + + period_start = target_day.isoformat() + period_end = (target_day + timedelta(days=1)).isoformat() + + db_path = args.db or None + if db_path: + init_db(db_path) + conn = get_connection(db_path) + else: + init_db() + conn = get_connection() + + with open(args.config) as f: + config = json.load(f) + + log.info("Collecte pour le %s", period_start) + + resource_period_end = target_day + timedelta(days=1) + resource_period_start = resource_period_end - timedelta(days=RESOURCE_WINDOW_DAYS) + + for provider, entries in (("aws", config.get("aws", [])), ("azure", config.get("azure", []))): + for entry in entries: + ok = collect_tenant(conn, provider, entry, period_start, period_end) + if ok: + collect_tenant_resources( + conn, provider, entry, + resource_period_start.isoformat(), resource_period_end.isoformat(), + ) + + conn.close() + log.info("Terminé.") + + +if __name__ == "__main__": + main() diff --git a/collector/cost_report.py b/collector/cost_report.py new file mode 100644 index 0000000..126c59c --- /dev/null +++ b/collector/cost_report.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +cost_report.py — Récupère les coûts de plusieurs tenants AWS et Azure. + +Utilisation : + python cost_report.py # coûts du mois en cours (MTD) + python cost_report.py --start 2026-06-01 --end 2026-07-01 + python cost_report.py --config mon_config.json + python cost_report.py --json # sortie JSON au lieu du tableau + +Prérequis : + pip install -r requirements.txt + +Config : + Copier config.example.json en config.json et remplir tes comptes. + +Permissions nécessaires : + - AWS : le profil doit avoir le droit IAM "ce:GetCostAndUsage" + (policy managée "Billing" ou custom policy dessus). + - Azure : le service principal doit avoir le rôle "Cost Management Reader" + (ou "Reader") sur la subscription visée. +""" + +import argparse +import json +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import date, timedelta +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from providers import get_aws_cost, get_azure_cost, convert_result + + +# ---------------------------------------------------------------------- +# Orchestration +# ---------------------------------------------------------------------- + +def default_period(): + """Du 1er du mois en cours à aujourd'hui (Cost Explorer/Cost Management + veulent 'end' exclusif).""" + today = date.today() + start = today.replace(day=1) + end = today + timedelta(days=1) + return start.isoformat(), end.isoformat() + + +def run(config_path: str, start: str, end: str, target_currency: str = None): + with open(config_path) as f: + config = json.load(f) + + results = [] + + def handle_aws(entry): + name = entry["name"] + try: + data = get_aws_cost(entry, start, end) + if target_currency: + data = convert_result(data, target_currency) + return {"provider": "AWS", "name": name, "status": "ok", **data} + except Exception as e: + return {"provider": "AWS", "name": name, "status": "error", "error": str(e)} + + def handle_azure(entry): + name = entry["name"] + try: + data = get_azure_cost( + entry["tenant_id"], entry["client_id"], entry["client_secret"], + entry["subscription_id"], start, end, + ) + if target_currency: + data = convert_result(data, target_currency) + return {"provider": "Azure", "name": name, "status": "ok", **data} + except Exception as e: + return {"provider": "Azure", "name": name, "status": "error", "error": str(e)} + + tasks = [] + with ThreadPoolExecutor(max_workers=10) as pool: + for entry in config.get("aws", []): + tasks.append(pool.submit(handle_aws, entry)) + for entry in config.get("azure", []): + tasks.append(pool.submit(handle_azure, entry)) + + for t in as_completed(tasks): + results.append(t.result()) + + return results + + +def print_table(results, start, end): + print(f"\nCoûts du {start} au {end}\n") + header = f"{'Provider':8} {'Tenant':30} {'Statut':8} {'Total':>12} Devise" + print(header) + print("-" * len(header)) + + grand_total_by_currency = {} + + for r in sorted(results, key=lambda x: (x["provider"], x["name"])): + if r["status"] == "ok": + line = f"{r['provider']:8} {r['name']:30} {'OK':8} {r['total']:>12.2f} {r['currency']}" + if r.get("original_currency") and r["original_currency"] != r["currency"]: + line += f" (orig. {r['original_total']:.2f} {r['original_currency']})" + if r.get("conversion_error"): + line += f" [conversion échouée: {r['conversion_error'][:40]}]" + print(line) + grand_total_by_currency[r["currency"]] = grand_total_by_currency.get(r["currency"], 0) + r["total"] + else: + print(f"{r['provider']:8} {r['name']:30} {'ERREUR':8} {r['error'][:60]}") + + print("-" * len(header)) + for currency, total in grand_total_by_currency.items(): + print(f"{'TOTAL':8} {'':30} {'':8} {total:>12.2f} {currency}") + if len(grand_total_by_currency) > 1: + print(" (Attention : plusieurs devises encore présentes, vérifie les erreurs de conversion ci-dessus)") + + +def main(): + parser = argparse.ArgumentParser(description="Rapport de coûts multi-tenants AWS/Azure") + parser.add_argument("--config", default="config.json", help="Chemin vers le fichier de config") + parser.add_argument("--start", help="Date de début (YYYY-MM-DD), défaut: 1er du mois en cours") + parser.add_argument("--end", help="Date de fin exclusive (YYYY-MM-DD), défaut: demain") + parser.add_argument("--json", action="store_true", help="Sortie en JSON brut") + parser.add_argument("--detail", action="store_true", help="Afficher la répartition par service") + parser.add_argument("--currency", default="EUR", + help="Devise cible pour la conversion (défaut: EUR). " + "Utiliser --no-convert pour désactiver.") + parser.add_argument("--no-convert", action="store_true", + help="Désactive la conversion, affiche les montants dans leur devise d'origine") + args = parser.parse_args() + + default_start, default_end = default_period() + start = args.start or default_start + end = args.end or default_end + + target_currency = None if args.no_convert else args.currency + results = run(args.config, start, end, target_currency) + + if args.json: + print(json.dumps({"start": start, "end": end, "results": results}, indent=2, ensure_ascii=False)) + return + + print_table(results, start, end) + + if args.detail: + for r in results: + if r["status"] == "ok" and r.get("by_service"): + print(f"\n--- Détail {r['provider']} / {r['name']} ---") + for service, amount in list(r["by_service"].items())[:15]: + print(f" {service:45} {amount:>10.2f} {r['currency']}") + + +if __name__ == "__main__": + main() diff --git a/collector/db.py b/collector/db.py new file mode 100644 index 0000000..46d1774 --- /dev/null +++ b/collector/db.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +db.py — Connexion SQLite + écriture des relevés de coûts. + +Utilisé par collect.py (collecte journalière) et import_historical.py +(import ponctuel). Le schéma vit dans db/schema.sql. +""" + +from __future__ import annotations + +import os +import sqlite3 +from pathlib import Path + +DEFAULT_DB_PATH = "cost_dashboard.db" +SCHEMA_PATH = Path(__file__).parent.parent / "db" / "schema.sql" + + +def _resolve_db_path(db_path: str | None = None) -> str: + return db_path or os.environ.get("COST_DASHBOARD_DB") or DEFAULT_DB_PATH + + +def init_db(db_path: str | None = None) -> None: + """Crée le fichier SQLite et applique le schéma s'il n'existe pas déjà.""" + path = _resolve_db_path(db_path) + Path(path).parent.mkdir(parents=True, exist_ok=True) + + conn = sqlite3.connect(path) + try: + conn.executescript(SCHEMA_PATH.read_text()) + conn.commit() + finally: + conn.close() + + +def get_connection(db_path: str | None = None) -> sqlite3.Connection: + """Ouvre une connexion SQLite (foreign keys activées, row factory par nom).""" + path = _resolve_db_path(db_path) + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def upsert_collection( + conn: sqlite3.Connection, + provider: str, + tenant_name: str, + granularity: str, + period_start: str, + period_end: str, + collected_at: str, + status: str, + data: dict | None = None, + error_message: str | None = None, +) -> int: + """Insère ou remplace un relevé (provider, tenant_name, granularity, + period_start) et sa répartition par service. `data` est le dict retourné + par providers.get_aws_cost/get_azure_cost (+ convert_result) : total, + currency, by_service, original_total, original_currency, exchange_rate. + """ + data = data or {} + + conn.execute( + """ + INSERT INTO collections ( + provider, tenant_name, granularity, period_start, period_end, + collected_at, status, error_message, + total_amount, currency, original_total, original_currency, exchange_rate + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, tenant_name, granularity, period_start) DO UPDATE SET + period_end = excluded.period_end, + collected_at = excluded.collected_at, + status = excluded.status, + error_message = excluded.error_message, + total_amount = excluded.total_amount, + currency = excluded.currency, + original_total = excluded.original_total, + original_currency = excluded.original_currency, + exchange_rate = excluded.exchange_rate + """, + ( + provider, tenant_name, granularity, period_start, period_end, + collected_at, status, error_message, + data.get("total"), data.get("currency"), + data.get("original_total"), data.get("original_currency"), data.get("exchange_rate"), + ), + ) + + # last_insert_rowid() n'est pas fiable ici : sur la branche ON CONFLICT DO + # UPDATE (pas de nouvelle ligne créée), SQLite ne le met à jour que si + # aucun autre INSERT n'a eu lieu sur la connexion depuis — ce qui n'est + # pas garanti (ex: service_costs d'un tenant précédent). On relit l'id + # explicitement via la clé unique. + collection_id = conn.execute( + """ + SELECT id FROM collections + WHERE provider = ? AND tenant_name = ? AND granularity = ? AND period_start = ? + """, + (provider, tenant_name, granularity, period_start), + ).fetchone()["id"] + + conn.execute("DELETE FROM service_costs WHERE collection_id = ?", (collection_id,)) + by_service = data.get("by_service") or {} + if by_service: + conn.executemany( + "INSERT INTO service_costs (collection_id, service_name, amount) VALUES (?, ?, ?)", + [(collection_id, service, amount) for service, amount in by_service.items()], + ) + + conn.commit() + return collection_id + + +def set_resource_collection_status( + conn: sqlite3.Connection, + provider: str, + tenant_name: str, + status: str, + collected_at: str, + message: str | None = None, +) -> None: + conn.execute( + """ + INSERT INTO resource_collection_status (provider, tenant_name, status, message, collected_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(provider, tenant_name) DO UPDATE SET + status = excluded.status, + message = excluded.message, + collected_at = excluded.collected_at + """, + (provider, tenant_name, status, message, collected_at), + ) + conn.commit() + + +def upsert_resource_costs( + conn: sqlite3.Connection, + provider: str, + tenant_name: str, + period_start: str, + period_end: str, + services: dict, +) -> None: + """Remplace le cliché de coûts par ressource pour ce tenant (voir + resource_costs dans le schéma : c'est une photo glissante, pas un + historique — on supprime l'ancien cliché avant d'écrire le nouveau). + `services` : {service_name: [{resource_id, resource_name, amount}, ...]}, + la forme renvoyée par providers.get_*_resource_costs. + """ + conn.execute( + "DELETE FROM resource_costs WHERE provider = ? AND tenant_name = ?", + (provider, tenant_name), + ) + rows = [ + (provider, tenant_name, service, r["resource_id"], r["resource_name"], period_start, period_end, r["amount"]) + for service, resources in services.items() + for r in resources + ] + if rows: + conn.executemany( + """ + INSERT INTO resource_costs ( + provider, tenant_name, service_name, resource_id, resource_name, + period_start, period_end, amount + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + rows, + ) + conn.commit() diff --git a/collector/import_historical.py b/collector/import_historical.py new file mode 100644 index 0000000..b4ed464 --- /dev/null +++ b/collector/import_historical.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +import_historical.py — Importe en une fois d'anciennes données de coûts, +au format JSON produit par cost_report.py --json ou tenant_detail.py --json. + +Les données importées sont stockées en granularity='monthly' (une ligne = +une période, généralement un mois), pour ne pas se mélanger avec la +collecte journalière du cron qui alimente granularity='daily'. + +Formats acceptés (auto-détectés) : + +1. Sortie multi-tenants de cost_report.py --json : + {"start": "...", "end": "...", "results": [ + {"provider": "AWS", "name": "...", "status": "ok", "total": .., + "currency": "EUR", "by_service": {...}, ...}, + ... + ]} + +2. Sortie mono-tenant de tenant_detail.py --json : + {"tenant": "...", "provider": "AWS", "start": "...", "end": "...", + "total": .., "currency": "EUR", "by_service": {...}} + +Utilisation : + python import_historical.py --db ../cost_dashboard.db fichier1.json fichier2.json + python import_historical.py --db ../cost_dashboard.db historique/*.json +""" + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from db import get_connection, init_db, upsert_collection + + +def import_file(conn, path: str) -> int: + with open(path) as f: + payload = json.load(f) + + imported = 0 + collected_at = datetime.now(timezone.utc).isoformat() + + if "results" in payload: + # Format multi-tenants (cost_report.py --json) + start, end = payload["start"], payload["end"] + for r in payload["results"]: + provider = r["provider"].lower() + if r["status"] != "ok": + continue + upsert_collection( + conn, provider, r["name"], "monthly", start, end, + collected_at, "ok", data=r, + ) + imported += 1 + + elif "tenant" in payload: + # Format mono-tenant (tenant_detail.py --json) + provider = payload["provider"].lower() + upsert_collection( + conn, provider, payload["tenant"], "monthly", + payload["start"], payload["end"], collected_at, "ok", data=payload, + ) + imported += 1 + + else: + print(f" Format non reconnu, ignoré : {path}", file=sys.stderr) + + return imported + + +def main(): + parser = argparse.ArgumentParser(description="Import ponctuel de données de coûts historiques") + parser.add_argument("--db", default=None, help="Chemin vers la base SQLite") + parser.add_argument("files", nargs="+", help="Fichier(s) JSON à importer") + args = parser.parse_args() + + if args.db: + init_db(args.db) + conn = get_connection(args.db) + else: + init_db() + conn = get_connection() + + total_imported = 0 + for path in args.files: + n = import_file(conn, path) + print(f"{path} : {n} relevé(s) importé(s)") + total_imported += n + + conn.close() + print(f"\nTotal : {total_imported} relevé(s) importé(s).") + + +if __name__ == "__main__": + main() diff --git a/collector/providers.py b/collector/providers.py new file mode 100644 index 0000000..07e21eb --- /dev/null +++ b/collector/providers.py @@ -0,0 +1,395 @@ +""" +providers.py — Accès aux API de coûts AWS et Azure, + conversion de devise. + +Module partagé entre le collecteur cron (collect.py), l'import historique +(import_historical.py) et les scripts CLI ponctuels. Toute la logique +d'authentification et d'agrégation vit ici, une seule fois. +""" + +from __future__ import annotations + + +class ResourceLevelUnavailable(Exception): + """Levée quand AWS/Azure refuse une demande de détail par ressource — + le plus souvent parce que "Resource IDs" n'est pas activé côté AWS + Cost Explorer, ou qu'une fenêtre de plus de 14 jours a été demandée à + AWS. Distincte d'une erreur générique pour que l'appelant puisse + l'afficher comme un état "indisponible", pas comme un échec.""" + + +def _short_resource_name(resource_id: str) -> str: + """Dernier segment d'un ARN AWS ou d'un resource ID Azure, pour + l'affichage (ex: 'arn:aws:s3:::my-bucket' -> 'my-bucket', + '/subscriptions/.../virtualMachines/vm-1' -> 'vm-1').""" + if "/" in resource_id: + return resource_id.rsplit("/", 1)[-1] + if ":" in resource_id: + return resource_id.rsplit(":", 1)[-1] + return resource_id + + +# ---------------------------------------------------------------------- +# AWS +# ---------------------------------------------------------------------- + +def _aws_session(entry: dict): + """Construit la session boto3 pour un tenant AWS. + + Deux modes, au choix par tenant dans config.json : + - 'access_key_id' + 'secret_access_key' : clé IAM dédiée au tenant + (droit ce:GetCostAndUsage uniquement) — le seul mode qui fonctionne + en conteneur/Kubernetes, car il ne dépend d'aucun état sur l'hôte. + - 'profile' : profil ~/.aws/{credentials,config} existant — pratique en + local, mais suppose ~/.aws monté dans le conteneur, et ne fonctionne + pas du tout avec un profil SSO (le refresh token expire et sa + reconduction demande un navigateur, indisponible en conteneur). + """ + import boto3 + + name = entry.get("name", "?") + if entry.get("access_key_id") and entry.get("secret_access_key"): + return boto3.Session( + aws_access_key_id=entry["access_key_id"], + aws_secret_access_key=entry["secret_access_key"], + ) + if entry.get("profile"): + return boto3.Session(profile_name=entry["profile"]) + raise ValueError( + f"Tenant AWS '{name}' : config.json doit fournir soit 'profile', " + "soit 'access_key_id' + 'secret_access_key'." + ) + + +def get_aws_cost(entry: dict, start: str, end: str, granularity: str = "MONTHLY") -> dict: + """Coût total + répartition par service pour un tenant AWS (voir + _aws_session pour les modes d'authentification), sur la période + [start, end) (end exclusif, format YYYY-MM-DD). + + granularity: 'DAILY' ou 'MONTHLY' (voir doc AWS Cost Explorer). + """ + session = _aws_session(entry) + ce = session.client("ce", region_name="us-east-1") # Cost Explorer = toujours us-east-1 + + resp = ce.get_cost_and_usage( + TimePeriod={"Start": start, "End": end}, + Granularity=granularity, + Metrics=["UnblendedCost"], + GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}], + ) + + total = 0.0 + by_service: dict[str, float] = {} + currency = "USD" + for period in resp["ResultsByTime"]: + for group in period["Groups"]: + service = group["Keys"][0] + amount = float(group["Metrics"]["UnblendedCost"]["Amount"]) + currency = group["Metrics"]["UnblendedCost"]["Unit"] + by_service[service] = by_service.get(service, 0.0) + amount + total += amount + + return { + "total": round(total, 2), + "currency": currency, + "by_service": {k: round(v, 2) for k, v in sorted(by_service.items(), key=lambda x: -x[1])}, + } + + +def get_aws_resource_costs(entry: dict, start: str, end: str) -> dict: + """Coût par ressource (bucket S3, instance EC2, fonction Lambda...), + groupé par service, sur la période [start, end). Le détail par ressource + n'est PAS accessible via get_cost_and_usage (son GroupBy ne connaît pas + RESOURCE_ID) — il faut l'API dédiée get_cost_and_usage_with_resources, + qui impose les mêmes contraintes (14 jours max, "Resource IDs" activé + côté compte). Lève ResourceLevelUnavailable si AWS refuse, plutôt que de + laisser planter l'appelant, pour que ce soit affichable comme un état + plutôt qu'une erreur de collecte.""" + from botocore.exceptions import ClientError + + session = _aws_session(entry) + ce = session.client("ce", region_name="us-east-1") + + try: + resp = ce.get_cost_and_usage_with_resources( + TimePeriod={"Start": start, "End": end}, + Granularity="DAILY", + Metrics=["UnblendedCost"], + GroupBy=[ + {"Type": "DIMENSION", "Key": "SERVICE"}, + {"Type": "DIMENSION", "Key": "RESOURCE_ID"}, + ], + Filter={"Dimensions": {"Key": "RECORD_TYPE", "Values": ["Usage"]}}, + ) + except ClientError as e: + raise ResourceLevelUnavailable(str(e)) from e + + services: dict[str, dict[str, float]] = {} + currency = "USD" + for period in resp["ResultsByTime"]: + for group in period["Groups"]: + service, resource_id = group["Keys"] + amount = float(group["Metrics"]["UnblendedCost"]["Amount"]) + currency = group["Metrics"]["UnblendedCost"]["Unit"] + bucket = services.setdefault(service, {}) + bucket[resource_id] = bucket.get(resource_id, 0.0) + amount + + return {"currency": currency, "services": _finalize_resource_buckets(services)} + + +def _finalize_resource_buckets(services: dict[str, dict[str, float]]) -> dict[str, list[dict]]: + """Transforme {service: {resource_id: montant}} en + {service: [{resource_id, resource_name, amount}, ...]} trié par coût + décroissant. 'NoResourceId' / id vide = coûts du service non rattachés + à une ressource précise (data transfer, support...).""" + result: dict[str, list[dict]] = {} + for service, resources in services.items(): + rows = [] + for rid, amount in resources.items(): + is_unattributed = not rid or rid == "NoResourceId" + rows.append({ + "resource_id": rid, + "resource_name": "Autres coûts (sans ressource associée)" if is_unattributed else _short_resource_name(rid), + "amount": round(amount, 2), + }) + rows.sort(key=lambda r: -r["amount"]) + result[service] = rows + return result + + +# ---------------------------------------------------------------------- +# Azure +# ---------------------------------------------------------------------- + +# Cost Management ne renvoie pas le `Retry-After` HTTP standard sur ses 429 — +# constaté en pratique : il renvoie ses propres en-têtes `x-ms-ratelimit- +# microsoft.costmanagement-*-retry-after`, un par niveau de quota (entité, +# tenant, type de client). On vérifie les deux formes, et on prend le délai +# le plus grand si plusieurs sont présents (le plus restrictif fait foi). +_AZURE_RETRY_AFTER_HEADERS = ( + "Retry-After", + "x-ms-ratelimit-microsoft.costmanagement-entity-retry-after", + "x-ms-ratelimit-microsoft.costmanagement-tenant-retry-after", + "x-ms-ratelimit-microsoft.costmanagement-clienttype-retry-after", +) + + +def _azure_retry_after_seconds(response) -> float | None: + if response is None: + return None + waits = [] + for header in _AZURE_RETRY_AFTER_HEADERS: + value = response.headers.get(header) + if value: + try: + waits.append(float(value)) + except ValueError: + pass + return max(waits) if waits else None + + +def _azure_query_with_retry(client, scope: str, query: dict, max_retries: int = 8): + """client.query.usage avec retry + backoff sur les 429. L'API Cost + Management est plus sujette au rate-limiting que Cost Explorer côté AWS + — le quota est souvent partagé au niveau du tenant Azure AD entier, pas + par service principal, donc même un tout premier appel peut être + limité — et azure-core ne retente pas toujours ces réponses tout seul + (constaté : un seul essai visible dans les logs avant l'erreur). + + max_retries=8 avec un plafond de repli à 60s (~5 min de patience max + cumulée) si Azure ne fournit aucun en-tête retry-after exploitable : + volontairement généreux, pensé pour un backfill qui peut se permettre + d'attendre plutôt que d'abandonner en cours de route.""" + from azure.core.exceptions import HttpResponseError + import time + + for attempt in range(max_retries): + try: + return client.query.usage(scope, query) + except HttpResponseError as e: + if e.status_code != 429 or attempt == max_retries - 1: + raise + wait = _azure_retry_after_seconds(e.response) + if wait is None: + wait = min(2 ** attempt, 60) + time.sleep(wait) + + +def get_azure_cost(tenant_id: str, client_id: str, client_secret: str, + subscription_id: str, start: str, end: str) -> dict: + """Coût total + répartition par service pour une subscription Azure, + sur la période [start, end).""" + from azure.identity import ClientSecretCredential + from azure.mgmt.costmanagement import CostManagementClient + + credential = ClientSecretCredential(tenant_id, client_id, client_secret) + client = CostManagementClient(credential) + scope = f"/subscriptions/{subscription_id}" + + query = { + "type": "ActualCost", + "timeframe": "Custom", + "timePeriod": {"from": f"{start}T00:00:00+00:00", "to": f"{end}T00:00:00+00:00"}, + "dataset": { + "granularity": "None", + "aggregation": {"totalCost": {"name": "PreTaxCost", "function": "Sum"}}, + "grouping": [{"type": "Dimension", "name": "ServiceName"}], + }, + } + + result = _azure_query_with_retry(client, scope, query) + + columns = [c.name for c in result.columns] + cost_idx = columns.index("PreTaxCost") + currency_idx = columns.index("Currency") if "Currency" in columns else None + service_idx = columns.index("ServiceName") if "ServiceName" in columns else None + + total = 0.0 + by_service: dict[str, float] = {} + currency = "EUR" + for row in result.rows: + amount = float(row[cost_idx]) + total += amount + if service_idx is not None: + service = row[service_idx] + by_service[service] = by_service.get(service, 0.0) + amount + if currency_idx is not None: + currency = row[currency_idx] + + return { + "total": round(total, 2), + "currency": currency, + "by_service": {k: round(v, 2) for k, v in sorted(by_service.items(), key=lambda x: -x[1])}, + } + + +def get_azure_resource_costs(tenant_id: str, client_id: str, client_secret: str, + subscription_id: str, start: str, end: str) -> dict: + """Coût par ressource (VM, storage account...), groupé par service, sur + la période [start, end). Azure n'impose pas de limite de fenêtre pour ce + niveau de détail (contrairement à AWS) — on garde quand même 14 jours + en pratique pour rester cohérent entre les deux providers.""" + from azure.identity import ClientSecretCredential + from azure.mgmt.costmanagement import CostManagementClient + from azure.core.exceptions import HttpResponseError + + credential = ClientSecretCredential(tenant_id, client_id, client_secret) + client = CostManagementClient(credential) + scope = f"/subscriptions/{subscription_id}" + + query = { + "type": "ActualCost", + "timeframe": "Custom", + "timePeriod": {"from": f"{start}T00:00:00+00:00", "to": f"{end}T00:00:00+00:00"}, + "dataset": { + "granularity": "None", + "aggregation": {"totalCost": {"name": "PreTaxCost", "function": "Sum"}}, + "grouping": [ + {"type": "Dimension", "name": "ServiceName"}, + {"type": "Dimension", "name": "ResourceId"}, + ], + }, + } + + try: + result = _azure_query_with_retry(client, scope, query) + except HttpResponseError as e: + raise ResourceLevelUnavailable(str(e)) from e + + columns = [c.name for c in result.columns] + cost_idx = columns.index("PreTaxCost") + currency_idx = columns.index("Currency") if "Currency" in columns else None + service_idx = columns.index("ServiceName") if "ServiceName" in columns else None + resource_idx = columns.index("ResourceId") if "ResourceId" in columns else None + + services: dict[str, dict[str, float]] = {} + currency = "EUR" + for row in result.rows: + amount = float(row[cost_idx]) + if currency_idx is not None: + currency = row[currency_idx] + service = row[service_idx] if service_idx is not None else "(service inconnu)" + resource_id = row[resource_idx] if resource_idx is not None else "" + bucket = services.setdefault(service, {}) + bucket[resource_id] = bucket.get(resource_id, 0.0) + amount + + return {"currency": currency, "services": _finalize_resource_buckets(services)} + + +# ---------------------------------------------------------------------- +# Conversion de devise +# ---------------------------------------------------------------------- + +_rate_cache: dict[tuple[str, str], float] = {} + + +def get_exchange_rate(from_currency: str, to_currency: str) -> float: + """Taux de change du jour (source: BCE, via l'API gratuite frankfurter.app). + Mis en cache pour la durée du process.""" + if from_currency == to_currency: + return 1.0 + + key = (from_currency, to_currency) + if key in _rate_cache: + return _rate_cache[key] + + import requests + + resp = requests.get( + "https://api.frankfurter.app/latest", + params={"from": from_currency, "to": to_currency}, + timeout=10, + ) + resp.raise_for_status() + rate = resp.json()["rates"][to_currency] + _rate_cache[key] = rate + return rate + + +def convert_result(result: dict, target_currency: str) -> dict: + """Convertit un résultat (total + by_service) vers la devise cible. + Ajoute 'original_total' / 'original_currency' / 'exchange_rate' pour + traçabilité. Le montant est figé : il ne sera plus reconverti ensuite.""" + original_currency = result["currency"] + + if original_currency == target_currency: + result["original_total"] = result["total"] + result["original_currency"] = original_currency + result["exchange_rate"] = 1.0 + return result + + try: + rate = get_exchange_rate(original_currency, target_currency) + except Exception as e: + result["conversion_error"] = str(e) + return result + + result["original_total"] = result["total"] + result["original_currency"] = original_currency + result["exchange_rate"] = rate + result["total"] = round(result["total"] * rate, 2) + result["currency"] = target_currency + if result.get("by_service"): + result["by_service"] = {k: round(v * rate, 2) for k, v in result["by_service"].items()} + return result + + +def convert_resource_costs(result: dict, target_currency: str) -> dict: + """Équivalent de convert_result pour le résultat de + get_aws_resource_costs / get_azure_resource_costs (par ressource, + groupé par service).""" + original_currency = result["currency"] + if original_currency == target_currency: + return result + + try: + rate = get_exchange_rate(original_currency, target_currency) + except Exception as e: + result["conversion_error"] = str(e) + return result + + result["currency"] = target_currency + result["services"] = { + service: [{**r, "amount": round(r["amount"] * rate, 2)} for r in resources] + for service, resources in result["services"].items() + } + return result diff --git a/collector/tenant_detail.py b/collector/tenant_detail.py new file mode 100644 index 0000000..7045903 --- /dev/null +++ b/collector/tenant_detail.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +tenant_detail.py — Détail des coûts par famille de service (S3, EC2, ECS, +Fargate, etc.) pour UN tenant spécifique, converti en EUR par défaut. + +Ce script réutilise providers.py (accès AWS/Azure) et default_period() de +cost_report.py : il doit rester dans le même dossier que ces deux modules. + +Utilisation : + python tenant_detail.py --tenant analytics-platform + python tenant_detail.py --tenant client-c-prod --start 2026-06-01 --end 2026-07-01 + python tenant_detail.py --tenant analytics-platform --top 15 + python tenant_detail.py --tenant analytics-platform --json + python tenant_detail.py --tenant analytics-platform --no-convert # devise d'origine +""" + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from providers import get_aws_cost, get_azure_cost, convert_result +from cost_report import default_period + + +def find_tenant(config: dict, tenant_name: str): + """Cherche le tenant par son 'name' dans les sections aws/azure de la config. + Retourne (provider, entry) ou (None, None) si introuvable.""" + for entry in config.get("aws", []): + if entry["name"] == tenant_name: + return "aws", entry + for entry in config.get("azure", []): + if entry["name"] == tenant_name: + return "azure", entry + return None, None + + +def main(): + parser = argparse.ArgumentParser( + description="Détail des coûts par famille de service pour un tenant donné" + ) + parser.add_argument("--tenant", required=True, + help="Nom du tenant tel que défini dans config.json") + parser.add_argument("--config", default="config.json", help="Chemin vers le fichier de config") + parser.add_argument("--start", help="Date de début (YYYY-MM-DD), défaut: 1er du mois en cours") + parser.add_argument("--end", help="Date de fin exclusive (YYYY-MM-DD), défaut: demain") + parser.add_argument("--currency", default="EUR", help="Devise cible (défaut: EUR)") + parser.add_argument("--no-convert", action="store_true", + help="Désactive la conversion, garde la devise d'origine") + parser.add_argument("--top", type=int, default=None, + help="N'afficher que les N services les plus coûteux") + parser.add_argument("--json", action="store_true", help="Sortie en JSON brut") + args = parser.parse_args() + + try: + with open(args.config) as f: + config = json.load(f) + except FileNotFoundError: + print(f"Fichier de config introuvable : {args.config}", file=sys.stderr) + sys.exit(1) + + provider, entry = find_tenant(config, args.tenant) + if entry is None: + available = [e["name"] for e in config.get("aws", [])] + \ + [e["name"] for e in config.get("azure", [])] + print(f"Tenant '{args.tenant}' introuvable dans {args.config}", file=sys.stderr) + print(f"Tenants disponibles : {', '.join(available) or '(aucun)'}", file=sys.stderr) + sys.exit(1) + + default_start, default_end = default_period() + start = args.start or default_start + end = args.end or default_end + + try: + if provider == "aws": + data = get_aws_cost(entry, start, end) + else: + data = get_azure_cost( + entry["tenant_id"], entry["client_id"], entry["client_secret"], + entry["subscription_id"], start, end, + ) + except Exception as e: + print(f"Erreur lors de la récupération des coûts pour '{args.tenant}' : {e}", file=sys.stderr) + sys.exit(1) + + target_currency = None if args.no_convert else args.currency + if target_currency: + data = convert_result(data, target_currency) + + if args.json: + print(json.dumps( + {"tenant": args.tenant, "provider": provider.upper(), "start": start, "end": end, **data}, + indent=2, ensure_ascii=False, + )) + return + + print(f"\nDétail des coûts — {args.tenant} ({provider.upper()})") + print(f"Période : {start} → {end}") + + if data.get("original_currency") and data["original_currency"] != data["currency"]: + print(f"Total : {data['total']:.2f} {data['currency']} " + f"(orig. {data['original_total']:.2f} {data['original_currency']})") + else: + print(f"Total : {data['total']:.2f} {data['currency']}") + + if data.get("conversion_error"): + print(f"[Conversion échouée, montant affiché dans sa devise d'origine : {data['conversion_error']}]") + + services = list(data.get("by_service", {}).items()) + if args.top: + services = services[:args.top] + + print() + if not services: + print("Aucun coût détecté sur la période pour ce tenant.") + return + + total = data["total"] or 1 # évite division par zéro + header = f"{'Famille de service':45} {'Montant':>12} {'%':>6}" + print(header) + print("-" * len(header)) + for service, amount in services: + pct = (amount / total) * 100 + print(f"{service:45} {amount:>12.2f} {pct:>5.1f}%") + print("-" * len(header)) + print(f"{'TOTAL':45} {data['total']:>12.2f} {'100.0':>5}% ({data['currency']})") + + +if __name__ == "__main__": + main() diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..566435e --- /dev/null +++ b/config.example.json @@ -0,0 +1,22 @@ +{ + "aws": [ + { + "name": "client-a-prod", + "profile": "client-a-prod" + }, + { + "name": "client-b-prod", + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "VOTRE_SECRET_ICI" + } + ], + "azure": [ + { + "name": "client-c-prod", + "tenant_id": "00000000-0000-0000-0000-000000000000", + "client_id": "11111111-1111-1111-1111-111111111111", + "client_secret": "VOTRE_SECRET_ICI", + "subscription_id": "22222222-2222-2222-2222-222222222222" + } + ] +} diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..e1fc2d7 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,76 @@ +-- simple-cost-dashboard — schéma SQLite +-- +-- Une "collection" = un relevé de coût pour un tenant sur une période donnée. +-- granularity='daily' -> alimenté par le cron (1 jour = 1 ligne) +-- granularity='monthly' -> alimenté par l'import historique (1 mois = 1 ligne), +-- utilisé pour les données antérieures à la mise en +-- place de la collecte journalière. +-- +-- Le montant est figé en EUR au moment de la collecte (avec le taux utilisé), +-- pour que l'historique affiché ne bouge pas quand le taux de change évolue. + +CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL CHECK(provider IN ('aws', 'azure')), + tenant_name TEXT NOT NULL, + granularity TEXT NOT NULL CHECK(granularity IN ('daily', 'monthly')), + period_start DATE NOT NULL, -- inclusif + period_end DATE NOT NULL, -- exclusif + collected_at TIMESTAMP NOT NULL, + status TEXT NOT NULL CHECK(status IN ('ok', 'error')), + error_message TEXT, + total_amount REAL, -- en EUR (figé) + currency TEXT, -- 'EUR' + original_total REAL, -- montant avant conversion + original_currency TEXT, -- devise de facturation d'origine + exchange_rate REAL, -- taux appliqué au moment de la collecte + UNIQUE(provider, tenant_name, granularity, period_start) +); + +CREATE INDEX IF NOT EXISTS idx_collections_lookup + ON collections(provider, tenant_name, granularity, period_start); + +CREATE TABLE IF NOT EXISTS service_costs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + service_name TEXT NOT NULL, + amount REAL NOT NULL -- en EUR (figé) +); + +CREATE INDEX IF NOT EXISTS idx_service_costs_collection + ON service_costs(collection_id); + +-- Détail par ressource (bucket S3, instance EC2, fonction Lambda, VM Azure...). +-- Cliché glissant sur les 14 derniers jours, réécrit à chaque collecte — pas +-- un historique jour par jour comme `collections` : AWS Cost Explorer ne +-- fournit le détail par ressource que sur cette fenêtre (limite dure de +-- l'API, indépendante de notre code), donc conserver plus d'historique +-- n'apporterait rien. Pour rester cohérent entre providers, Azure (qui n'a +-- pas cette limite) utilise la même fenêtre de 14 jours. +CREATE TABLE IF NOT EXISTS resource_costs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL CHECK(provider IN ('aws', 'azure')), + tenant_name TEXT NOT NULL, + service_name TEXT NOT NULL, + resource_id TEXT NOT NULL, -- ARN AWS / resource ID Azure complet + resource_name TEXT NOT NULL, -- dernier segment, pour l'affichage + period_start DATE NOT NULL, + period_end DATE NOT NULL, + amount REAL NOT NULL, -- en EUR (figé), somme sur la fenêtre + UNIQUE(provider, tenant_name, service_name, resource_id) +); + +CREATE INDEX IF NOT EXISTS idx_resource_costs_lookup + ON resource_costs(provider, tenant_name, service_name); + +-- Statut de la collecte resource-level, séparé de `collections` : elle peut +-- échouer pour une raison différente (ex: "Resource IDs" non activé côté +-- AWS Cost Explorer) sans que la collecte quotidienne normale soit en cause. +CREATE TABLE IF NOT EXISTS resource_collection_status ( + provider TEXT NOT NULL CHECK(provider IN ('aws', 'azure')), + tenant_name TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('ok', 'unavailable', 'error')), + message TEXT, + collected_at TIMESTAMP NOT NULL, + PRIMARY KEY (provider, tenant_name) +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2117172 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,76 @@ + + + + + +simple-cost-dashboard + + + + + +
+
+

simple-cost-dashboard

+ coûts AWS / Azure, tous tenants +
+
+ +
+ +
+ +
+
+

Coûts — mois en cours

+

+
+
+

AWS — mois en cours

+

+
+
+

Azure — mois en cours

+

+
+
+

Tenants en échec de collecte

+

+
+
+ +

Évolution mensuelle (12 mois glissants)

+
+
+
+

Coûts par mois

+

Total AWS + Azure, EUR

+
+
+
+ +
+
+ AWS + Azure +
+
+ +

Tenants

+
+
+ +
+
+

Clique un ou plusieurs mois dans le graphe ci-dessus pour filtrer la table sur cette période.

+
+
Chargement…
+
+
+ +
+ + + + + diff --git a/frontend/static/app.js b/frontend/static/app.js new file mode 100644 index 0000000..57bd835 --- /dev/null +++ b/frontend/static/app.js @@ -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 = ` + `; + } 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 = ` + Sélection : ${labels} + `; + 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 = `
Aucun tenant collecté pour l'instant. Lance collect.py ou import_historical.py pour peupler le dashboard.
`; + 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 ` + + + + ${providerLabel(t.provider)} + ${escapeHtml(t.name)} + + + ${formatEUR(amount)} + + + ${st.label} + + + `; + } + + const delta = formatPct(t.delta_pct); + return ` + + + + ${providerLabel(t.provider)} + ${escapeHtml(t.name)} + + + ${formatEUR(t.current_month)} + ${formatEUR(t.previous_month)} + + ${delta ? `${deltaArrow(t.delta_pct)} ${delta}` : "—"} + + + + ${st.label} + + + `; + }) + .join(""); + + const header = selectionMode + ? `TenantMontant (sélection)Statut collecte` + : ` + Tenant + Mois en cours + Mois précédent + Évolution + Statut collecte + `; + + wrap.innerHTML = ` + + ${header} + ${rows} +
`; + + 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 = ` + `; + document.getElementById("tenants-table-wrap").innerHTML = ""; + } +} + +init(); diff --git a/frontend/static/common.js b/frontend/static/common.js new file mode 100644 index 0000000..3e8c0b8 --- /dev/null +++ b/frontend/static/common.js @@ -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, """); +} + +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 } }, + }, + }, + }; +} diff --git a/frontend/static/style.css b/frontend/static/style.css new file mode 100644 index 0000000..41dc559 --- /dev/null +++ b/frontend/static/style.css @@ -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; +} diff --git a/frontend/static/tenant.js b/frontend/static/tenant.js new file mode 100644 index 0000000..506ef71 --- /dev/null +++ b/frontend/static/tenant.js @@ -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 = + `${providerLabel(data.tenant.provider)} ${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 = ` + `; + } else if (!hasAnyData) { + const msg = isMonthly + ? "Aucune donnée mensuelle pour ce tenant — lance backfill_historical.py ou import_historical.py 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 = ` + `; + } 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 = `
Aucune donnée sur cette période.
`; + return; + } + if (!wrap.querySelector("canvas")) { + wrap.innerHTML = ``; + } + + 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 = `
Aucun coût par service sur cette période.
`; + 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 = ``; + } + + 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 = `
Rien à afficher.
`; + return; + } + + const total = entries.reduce((sum, [, amount]) => sum + amount, 0) || 1; + const rows = entries + .map(([name, amount]) => { + const pct = (amount / total) * 100; + return ` + + ${escapeHtml(name)} + ${formatEUR(amount)} + ${pct.toFixed(1)} % + `; + }) + .join(""); + + wrap.innerHTML = ` + + + + + ${rows} +
ServiceMontant%
`; + + 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 = ` + Sélection : ${ordered.map(labelFn).join(", ")} + `; + 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 = + ``; + } +} + +// --- 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 = `
Chargement…
`; + 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 = + ``; + }); +} + +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 = `
+ Le détail par ressource n'a pas encore été collecté pour ce tenant — + il arrive au prochain passage de la collecte quotidienne. +
`; + 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 = ``; + return; + } + + if (data.status === "error") { + bodyEl.innerHTML = ``; + return; + } + + if (!data.resources.length) { + bodyEl.innerHTML = `
Aucune ressource identifiée pour ce service sur cette période.
`; + 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 ` +
+
+
${escapeHtml(r.resource_name)}
+
${pct.toFixed(1)} %
+
+
${formatEUR(r.amount)}
+
`; + }) + .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 = + ``; + 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 = + ``; + } +} + +init(); diff --git a/frontend/tenant.html b/frontend/tenant.html new file mode 100644 index 0000000..6c2255d --- /dev/null +++ b/frontend/tenant.html @@ -0,0 +1,104 @@ + + + + + +Détail tenant — simple-cost-dashboard + + + + + +
+
+

Chargement…

+ ← Vue globale +
+
+ +
+ +
+ +
+
+

Total — période

+

+
+
+

Total — période précédente

+

+
+
+

Évolution

+

+
+
+

Total depuis le 1er janvier

+

+
+
+ +

Coûts

+
+
+
+

Coûts journaliers

+

EUR, converti au taux du jour de collecte

+
+
+ + + +
+
+
+ +
+

Clique une ou plusieurs barres pour filtrer la répartition par service ci-dessous.

+
+ +

Répartition par service (30 derniers jours)

+
+
+
+
+
+

Top services

+

 

+
+
+
+ +
+
+
+
+
+

Détail

+
+
+
+
Chargement…
+
+
+
+ +
+ +
+ + + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..531fa08 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.111 +uvicorn[standard]>=0.30 +boto3>=1.34 +azure-identity>=1.16 +azure-mgmt-costmanagement>=4.0 +requests>=2.31 \ No newline at end of file diff --git a/ressource_id_activation b/ressource_id_activation new file mode 100644 index 0000000..db8881d --- /dev/null +++ b/ressource_id_activation @@ -0,0 +1,3 @@ +Connecte-toi à la console AWS du compte payeur (le compte de management de l'organisation, pas un compte membre). +Va dans Billing and Cost Management → Cost Explorer → Preferences (ou "Cost Explorer settings"). +Active l'option de granularité par ressource (« Resource IDs » / « Receive resource-level data »).