first (big) draft

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