first (big) draft
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
.git/
|
||||
.gitignore
|
||||
*.db
|
||||
*.log
|
||||
config.json
|
||||
config.example.json
|
||||
historique/
|
||||
.env
|
||||
.DS_Store
|
||||
README.md
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
*.db
|
||||
*.log
|
||||
config.json
|
||||
data/
|
||||
.env
|
||||
.DS_Store
|
||||
.claude/
|
||||
+53
@@ -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,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ludal
|
||||
Copyright (c) 2026 ludovic.cartier@brainsys.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
|
||||
@@ -1,2 +1,290 @@
|
||||
# simple-cost-dashboard
|
||||
|
||||
Suivi centralisé des coûts AWS et Azure pour une multitude de tenants,
|
||||
avec conversion automatique en EUR et historique conservé en base SQLite.
|
||||
Dashboard web (page globale + détail par tenant) alimenté par une collecte
|
||||
quotidienne (cron).
|
||||
|
||||
---
|
||||
|
||||
## 1. Installation locale (sans Docker)
|
||||
|
||||
```bash
|
||||
cd simple-cost-dashboard
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 2. Configuration des tenants
|
||||
|
||||
Crée un fichier `config.json` à la racine du projet (à côté de ce README) :
|
||||
|
||||
```json
|
||||
{
|
||||
"aws": [
|
||||
{ "name": "analytics-platform", "profile": "analytics-platform" },
|
||||
{
|
||||
"name": "client-a-prod",
|
||||
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secret_access_key": "VOTRE_SECRET_ICI"
|
||||
}
|
||||
],
|
||||
"azure": [
|
||||
{
|
||||
"name": "client-c-prod",
|
||||
"tenant_id": "00000000-0000-0000-0000-000000000000",
|
||||
"client_id": "11111111-1111-1111-1111-111111111111",
|
||||
"client_secret": "VOTRE_SECRET_ICI",
|
||||
"subscription_id": "22222222-2222-2222-2222-222222222222"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **AWS** — deux modes au choix par tenant, dans l'ordre de priorité
|
||||
`access_key_id`/`secret_access_key` puis `profile` :
|
||||
- `access_key_id` + `secret_access_key` : clé IAM dédiée au tenant,
|
||||
limitée au droit `ce:GetCostAndUsage`. **Seul mode qui fonctionne en
|
||||
conteneur/Kubernetes** — il ne dépend d'aucun fichier ni état sur
|
||||
l'hôte, contrairement à `profile`.
|
||||
- `profile` : un profil déjà configuré dans `~/.aws/credentials` ou
|
||||
`~/.aws/config`, pratique en local. Nécessite de monter `~/.aws` dans
|
||||
le conteneur (`-v ~/.aws:/home/appuser/.aws:ro`) — et **ne fonctionne
|
||||
pas avec un profil SSO** en conteneur : le token mis en cache finit
|
||||
par expirer, et son renouvellement demande un navigateur, indisponible
|
||||
côté conteneur/CronJob. Réservé au dev local avec des clés statiques
|
||||
ou un profil SSO valide au moment du run.
|
||||
- **Azure** : un service principal par tenant (`az ad sp create-for-rbac`),
|
||||
avec le rôle **Cost Management Reader** sur la subscription visée.
|
||||
- Le champ `"name"` est l'identifiant utilisé partout (URLs, dashboard).
|
||||
Deux tenants peuvent avoir le même `name` sur des CSP différents (ex:
|
||||
`analytics-platform` en AWS **et** en Azure) — ils restent distingués
|
||||
par le couple `(provider, name)`, y compris dans les URLs
|
||||
(`/tenant/aws/analytics-platform` vs `/tenant/azure/analytics-platform`).
|
||||
|
||||
⚠️ `config.json` contient des secrets (`client_secret` Azure, `secret_access_key`
|
||||
AWS). Ne le commite pas — il est déjà exclu via `.gitignore`.
|
||||
|
||||
## 3. Base de données
|
||||
|
||||
La base SQLite (`cost_dashboard.db` par défaut, chemin surchargeable via
|
||||
`COST_DASHBOARD_DB`) et son schéma sont créés automatiquement au premier
|
||||
lancement de `collect.py`, `import_historical.py` ou de l'API. Rien à
|
||||
faire manuellement. Le fichier `db/schema.sql` décrit la structure.
|
||||
|
||||
## 4. Importer l'historique (une seule fois)
|
||||
|
||||
**Depuis AWS/Azure directement**, pour peupler la base avec tout l'historique
|
||||
disponible sans étape intermédiaire :
|
||||
|
||||
```bash
|
||||
cd collector
|
||||
python backfill_historical.py --config ../config.json --db ../cost_dashboard.db
|
||||
```
|
||||
|
||||
Rapatrie 12 mois par défaut (`--months` pour ajuster), un par un, pour
|
||||
chaque tenant de `config.json`. Limites à connaître :
|
||||
- **AWS Cost Explorer ne conserve que 12 mois glissants** — au-delà, l'API
|
||||
ne renvoie rien (pas une erreur), donc `--months` plus grand n'aide pas
|
||||
côté AWS. Azure dépend du contrat (souvent plus long).
|
||||
- Chaque mois interrogé est un appel Cost Explorer/Cost Management facturé
|
||||
(~0,01 $ la requête AWS) — pour 28 tenants sur 12 mois, prévoir ~336
|
||||
requêtes (quelques dollars, payés une fois).
|
||||
- `--tenant <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
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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>
|
||||
@@ -0,0 +1,270 @@
|
||||
// app.js — page globale : KPIs, graphique mensuel AWS/Azure (cliquable,
|
||||
// multi-sélection), table des tenants.
|
||||
|
||||
let allTenants = [];
|
||||
let currentMonths = [];
|
||||
let currentProvidersTotals = { aws: [], azure: [] };
|
||||
let monthlyChart = null;
|
||||
const selectedMonths = new Set();
|
||||
|
||||
async function loadOverview() {
|
||||
const res = await fetch("/api/overview?months=12");
|
||||
if (!res.ok) throw new Error(`API overview: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function renderKpis(data) {
|
||||
document.getElementById("kpi-total").textContent = formatEUR(data.kpis.total_current_month);
|
||||
document.getElementById("kpi-aws").textContent = formatEUR(data.kpis.total_aws_current_month);
|
||||
document.getElementById("kpi-azure").textContent = formatEUR(data.kpis.total_azure_current_month);
|
||||
|
||||
const errorCount = data.kpis.tenants_error.length;
|
||||
const errEl = document.getElementById("kpi-errors");
|
||||
errEl.textContent = String(errorCount);
|
||||
errEl.style.color = errorCount > 0 ? "var(--status-critical)" : "";
|
||||
|
||||
const banner = document.getElementById("error-banner");
|
||||
if (errorCount > 0) {
|
||||
const names = data.kpis.tenants_error
|
||||
.map((t) => `${providerLabel(t.provider)} / ${escapeHtml(t.name)}`)
|
||||
.join(", ");
|
||||
banner.innerHTML = `
|
||||
<div class="banner error">
|
||||
<div>
|
||||
<strong>${errorCount} tenant${errorCount > 1 ? "s" : ""} en échec de collecte</strong> :
|
||||
${names}. Voir la table ci-dessous ou <code>cron.log</code> pour le détail.
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
banner.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
function dimmed(hex) {
|
||||
return Chart.helpers.color(hex).alpha(0.3).rgbString();
|
||||
}
|
||||
|
||||
function renderMonthlyChart() {
|
||||
const labels = currentMonths.map(monthLabel);
|
||||
const surface = cssVar("--surface-1");
|
||||
const awsColor = cssVar("--provider-aws");
|
||||
const azureColor = cssVar("--provider-azure");
|
||||
|
||||
const colorFor = (base) => (ctx) => {
|
||||
if (selectedMonths.size === 0) return base;
|
||||
return selectedMonths.has(currentMonths[ctx.dataIndex]) ? base : dimmed(base);
|
||||
};
|
||||
|
||||
if (monthlyChart) {
|
||||
monthlyChart.destroy();
|
||||
monthlyChart = null;
|
||||
}
|
||||
|
||||
const ctx = document.getElementById("monthly-chart").getContext("2d");
|
||||
monthlyChart = new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "AWS",
|
||||
data: currentProvidersTotals.aws,
|
||||
backgroundColor: colorFor(awsColor),
|
||||
borderColor: surface,
|
||||
borderWidth: { top: 2, right: 0, bottom: 0, left: 0 },
|
||||
borderSkipped: false,
|
||||
borderRadius: { topLeft: 4, topRight: 4 },
|
||||
stack: "total",
|
||||
maxBarThickness: 24,
|
||||
},
|
||||
{
|
||||
label: "Azure",
|
||||
data: currentProvidersTotals.azure,
|
||||
backgroundColor: colorFor(azureColor),
|
||||
borderColor: surface,
|
||||
borderWidth: { top: 2, right: 0, bottom: 0, left: 0 },
|
||||
borderSkipped: false,
|
||||
stack: "total",
|
||||
maxBarThickness: 24,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...chartBaseOptions(),
|
||||
onHover: (evt, elements) => {
|
||||
evt.native.target.style.cursor = elements.length ? "pointer" : "default";
|
||||
},
|
||||
onClick: (evt, elements) => {
|
||||
if (!elements.length) return;
|
||||
const monthKey = currentMonths[elements[0].index];
|
||||
if (selectedMonths.has(monthKey)) {
|
||||
selectedMonths.delete(monthKey);
|
||||
} else {
|
||||
selectedMonths.add(monthKey);
|
||||
}
|
||||
// Ne pas détruire ce chart depuis son propre handler onClick — Chart.js
|
||||
// est encore en train de dispatcher l'événement. On reporte au tick
|
||||
// suivant pour éviter de corrompre ses listeners internes.
|
||||
setTimeout(() => {
|
||||
renderMonthlyChart();
|
||||
renderTenantsTable(filteredTenants());
|
||||
}, 0);
|
||||
},
|
||||
plugins: {
|
||||
...chartBaseOptions().plugins,
|
||||
tooltip: {
|
||||
...chartBaseOptions().plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (item) => `${item.dataset.label} : ${formatEUR(item.parsed.y)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { ...chartBaseOptions().scales.x, stacked: true },
|
||||
y: { ...chartBaseOptions().scales.y, stacked: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function statusInfo(tenant) {
|
||||
if (tenant.status === "ok") return { cls: "ok", label: "OK" };
|
||||
if (tenant.status === "error") return { cls: "error", label: "Erreur" };
|
||||
return { cls: "unknown", label: "Pas encore collecté" };
|
||||
}
|
||||
|
||||
function selectedMonthIndexes() {
|
||||
return currentMonths
|
||||
.map((mk, i) => (selectedMonths.has(mk) ? i : -1))
|
||||
.filter((i) => i >= 0);
|
||||
}
|
||||
|
||||
function renderSelectionNote() {
|
||||
const el = document.getElementById("month-selection-note");
|
||||
if (selectedMonths.size === 0) {
|
||||
el.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const labels = currentMonths.filter((mk) => selectedMonths.has(mk)).map(monthLabel).join(", ");
|
||||
el.innerHTML = `
|
||||
<span>Sélection : ${labels}</span>
|
||||
<button type="button" id="clear-selection">Réinitialiser</button>`;
|
||||
document.getElementById("clear-selection").addEventListener("click", () => {
|
||||
selectedMonths.clear();
|
||||
renderMonthlyChart();
|
||||
renderSelectionNote();
|
||||
renderTenantsTable(filteredTenants());
|
||||
});
|
||||
}
|
||||
|
||||
function renderTenantsTable(tenants) {
|
||||
const wrap = document.getElementById("tenants-table-wrap");
|
||||
renderSelectionNote();
|
||||
|
||||
if (tenants.length === 0) {
|
||||
wrap.innerHTML = `<div class="empty-state">Aucun tenant collecté pour l'instant. Lance <code>collect.py</code> ou <code>import_historical.py</code> pour peupler le dashboard.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const indexes = selectedMonthIndexes();
|
||||
const selectionMode = indexes.length > 0;
|
||||
|
||||
const rows = tenants
|
||||
.map((t) => {
|
||||
const st = statusInfo(t);
|
||||
|
||||
if (selectionMode) {
|
||||
const amount = indexes.reduce((sum, i) => sum + (t.monthly[i] || 0), 0);
|
||||
return `
|
||||
<tr class="clickable" data-provider="${t.provider}" data-name="${escapeHtml(t.name)}">
|
||||
<td>
|
||||
<span class="name-cell">
|
||||
<span class="provider-badge ${t.provider}">${providerLabel(t.provider)}</span>
|
||||
${escapeHtml(t.name)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">${formatEUR(amount)}</td>
|
||||
<td>
|
||||
<span class="status-label">
|
||||
<span class="status-dot ${st.cls}"></span>${st.label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
const delta = formatPct(t.delta_pct);
|
||||
return `
|
||||
<tr class="clickable" data-provider="${t.provider}" data-name="${escapeHtml(t.name)}">
|
||||
<td>
|
||||
<span class="name-cell">
|
||||
<span class="provider-badge ${t.provider}">${providerLabel(t.provider)}</span>
|
||||
${escapeHtml(t.name)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">${formatEUR(t.current_month)}</td>
|
||||
<td class="num">${formatEUR(t.previous_month)}</td>
|
||||
<td class="num kpi-delta ${deltaClass(t.delta_pct)}" style="justify-content:flex-end; display:flex;">
|
||||
${delta ? `${deltaArrow(t.delta_pct)} ${delta}` : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-label">
|
||||
<span class="status-dot ${st.cls}"></span>${st.label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const header = selectionMode
|
||||
? `<tr><th>Tenant</th><th class="num">Montant (sélection)</th><th>Statut collecte</th></tr>`
|
||||
: `<tr>
|
||||
<th>Tenant</th>
|
||||
<th class="num">Mois en cours</th>
|
||||
<th class="num">Mois précédent</th>
|
||||
<th class="num">Évolution</th>
|
||||
<th>Statut collecte</th>
|
||||
</tr>`;
|
||||
|
||||
wrap.innerHTML = `
|
||||
<table class="data-table">
|
||||
<thead>${header}</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
|
||||
wrap.querySelectorAll("tr.clickable").forEach((tr) => {
|
||||
tr.addEventListener("click", () => {
|
||||
window.location.href = `/tenant/${tr.dataset.provider}/${encodeURIComponent(tr.dataset.name)}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function filteredTenants() {
|
||||
const q = document.getElementById("tenant-search").value.trim().toLowerCase();
|
||||
if (!q) return allTenants;
|
||||
return allTenants.filter((t) => t.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function setupSearch() {
|
||||
document.getElementById("tenant-search").addEventListener("input", () => {
|
||||
renderTenantsTable(filteredTenants());
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const data = await loadOverview();
|
||||
allTenants = data.tenants;
|
||||
currentMonths = data.months;
|
||||
currentProvidersTotals = data.providers_totals;
|
||||
renderKpis(data);
|
||||
renderMonthlyChart();
|
||||
renderTenantsTable(allTenants);
|
||||
setupSearch();
|
||||
} catch (e) {
|
||||
document.getElementById("error-banner").innerHTML = `
|
||||
<div class="banner error"><strong>Erreur de chargement du dashboard</strong> : ${escapeHtml(e.message)}</div>`;
|
||||
document.getElementById("tenants-table-wrap").innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,96 @@
|
||||
// common.js — petits utilitaires partagés entre app.js (page globale)
|
||||
// et tenant.js (page détail tenant).
|
||||
|
||||
const eurFormatter = new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
function formatEUR(amount) {
|
||||
if (amount === null || amount === undefined) return "—";
|
||||
return eurFormatter.format(amount);
|
||||
}
|
||||
|
||||
function formatPct(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const sign = value > 0 ? "+" : "";
|
||||
return `${sign}${value.toFixed(1)} %`;
|
||||
}
|
||||
|
||||
function deltaClass(value) {
|
||||
if (value === null || value === undefined) return "flat";
|
||||
if (value > 0.5) return "up";
|
||||
if (value < -0.5) return "down";
|
||||
return "flat";
|
||||
}
|
||||
|
||||
function deltaArrow(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (value > 0.5) return "↑";
|
||||
if (value < -0.5) return "↓";
|
||||
return "→";
|
||||
}
|
||||
|
||||
function monthLabel(monthKey) {
|
||||
const [y, m] = monthKey.split("-").map(Number);
|
||||
const d = new Date(Date.UTC(y, m - 1, 1));
|
||||
return new Intl.DateTimeFormat("fr-FR", { month: "short", year: "2-digit", timeZone: "UTC" }).format(d);
|
||||
}
|
||||
|
||||
function dayLabel(isoDate) {
|
||||
const d = new Date(`${isoDate}T00:00:00Z`);
|
||||
return new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", timeZone: "UTC" }).format(d);
|
||||
}
|
||||
|
||||
function providerLabel(provider) {
|
||||
return provider === "aws" ? "AWS" : provider === "azure" ? "Azure" : provider;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cssVar(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
// Config partagée des axes/grilles Chart.js (gridlines discrètes, pas de
|
||||
// deuxième axe, tooltip alignée sur le design system).
|
||||
function chartBaseOptions() {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: cssVar("--surface-1"),
|
||||
titleColor: cssVar("--text-primary"),
|
||||
bodyColor: cssVar("--text-secondary"),
|
||||
borderColor: cssVar("--border"),
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: true,
|
||||
boxPadding: 4,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
border: { color: cssVar("--axis") },
|
||||
ticks: { color: cssVar("--text-muted"), font: { size: 11 } },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: cssVar("--gridline") },
|
||||
border: { display: false },
|
||||
ticks: { color: cssVar("--text-muted"), font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
/* simple-cost-dashboard — design tokens + composants partagés
|
||||
(page globale et page détail tenant) */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--surface-1: #fcfcfb; /* cartes */
|
||||
--plane: #f9f9f7; /* fond de page */
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--text-muted: #898781;
|
||||
--gridline: #e1e0d9;
|
||||
--axis: #c3c2b7;
|
||||
--border: rgba(11, 11, 11, 0.10);
|
||||
--delta-good: #006300;
|
||||
--delta-bad: #d03b3b;
|
||||
|
||||
--status-good: #0ca30c;
|
||||
--status-warning: #fab219;
|
||||
--status-serious: #ec835a;
|
||||
--status-critical: #d03b3b;
|
||||
|
||||
--provider-aws: #eb6834; /* orange — identité AWS */
|
||||
--provider-azure: #2a78d6; /* bleu — identité Azure */
|
||||
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #4a3aa7;
|
||||
--series-8: #e34948;
|
||||
--series-other: #c3c2b7;
|
||||
|
||||
--radius-card: 12px;
|
||||
--radius-control: 8px;
|
||||
--shadow-card: 0 1px 2px rgba(11, 11, 11, 0.04), 0 1px 8px rgba(11, 11, 11, 0.04);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--surface-1: #1a1a19;
|
||||
--plane: #0d0d0d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--gridline: #2c2c2a;
|
||||
--axis: #383835;
|
||||
--border: rgba(255, 255, 255, 0.10);
|
||||
--delta-good: #0ca30c;
|
||||
--delta-bad: #e66767;
|
||||
|
||||
--provider-aws: #d95926;
|
||||
--provider-azure: #3987e5;
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
--series-other: #383835;
|
||||
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.2), 0 1px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--plane);
|
||||
color: var(--text-primary);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Layout */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 32px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-title h1 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topbar-title .breadcrumb {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topbar-title .breadcrumb a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topbar-title .breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 32px 64px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 32px 0 12px;
|
||||
}
|
||||
|
||||
.section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Cartes / KPI */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
font-variant-numeric: proportional-nums;
|
||||
}
|
||||
|
||||
.kpi-delta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.kpi-delta.up { color: var(--delta-bad); }
|
||||
.kpi-delta.down { color: var(--delta-good); }
|
||||
.kpi-delta.flat { color: var(--text-muted); }
|
||||
|
||||
.kpi-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.kpi-row.no-comparison .kpi-card-comparison {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Grille de graphiques */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.chart-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
padding: 20px 20px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-card-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chart-card-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.chart-canvas-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Contrôles (recherche, plage 7j/30j) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
max-width: 100%;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-control);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: 2px solid var(--series-1);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.range-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.range-toggle button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.range-toggle button + button {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.range-toggle button.active {
|
||||
background: var(--series-1);
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selection-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.selection-note button {
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-control);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.selection-note button:hover {
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Tables */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
table.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table thead th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-table tbody tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-table tbody tr.clickable:hover {
|
||||
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.provider-badge.aws {
|
||||
color: var(--provider-aws);
|
||||
background: color-mix(in srgb, var(--provider-aws) 14%, transparent);
|
||||
}
|
||||
|
||||
.provider-badge.azure {
|
||||
color: var(--provider-azure);
|
||||
background: color-mix(in srgb, var(--provider-azure) 14%, transparent);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.status-dot.ok { background: var(--status-good); }
|
||||
.status-dot.error { background: var(--status-critical); }
|
||||
.status-dot.unknown { background: var(--text-muted); }
|
||||
|
||||
.status-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Bandeaux d'état / vides */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-control);
|
||||
font-size: 13px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.banner.error {
|
||||
background: color-mix(in srgb, var(--status-critical) 12%, var(--surface-1));
|
||||
color: var(--text-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--status-critical) 30%, transparent);
|
||||
}
|
||||
|
||||
.banner strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Panneau latéral (drawer) — détail par ressource d'un service */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.drawer-backdrop.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 440px;
|
||||
max-width: 92vw;
|
||||
background: var(--surface-1);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.14);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s ease;
|
||||
z-index: 41;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.drawer-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.drawer-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-control);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.drawer-close:hover {
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 20px 20px;
|
||||
}
|
||||
|
||||
.resource-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
}
|
||||
|
||||
.resource-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.resource-name {
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.resource-pct {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.resource-amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Chart.js — surcharges de style (couleurs de police/grille) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
.chartjs-tooltip {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
// tenant.js — page détail tenant : KPIs, graphique journalier (7j/30j),
|
||||
// répartition par service (30 derniers jours).
|
||||
|
||||
const SERIES_COLORS = [
|
||||
"--series-1", "--series-2", "--series-3", "--series-4",
|
||||
"--series-5", "--series-6", "--series-7", "--series-8",
|
||||
];
|
||||
const MAX_SERVICE_SLOTS = 8;
|
||||
|
||||
const pathParts = window.location.pathname.split("/").filter(Boolean); // ["tenant", provider, name]
|
||||
const PROVIDER = decodeURIComponent(pathParts[1] || "");
|
||||
const NAME = decodeURIComponent(pathParts[2] || "");
|
||||
|
||||
let currentRange = 30;
|
||||
let currentBuckets = [];
|
||||
let currentMode = "daily"; // "daily" | "monthly"
|
||||
let defaultServices = {};
|
||||
const selectedBuckets = new Set();
|
||||
|
||||
let dailyChart = null;
|
||||
let servicesChart = null;
|
||||
|
||||
async function loadDetail(range) {
|
||||
const res = await fetch(`/api/tenant/${PROVIDER}/${encodeURIComponent(NAME)}?range=${range}`);
|
||||
if (!res.ok) throw new Error(`API tenant: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function renderHeader(data) {
|
||||
document.title = `${NAME} — simple-cost-dashboard`;
|
||||
document.getElementById("tenant-title").innerHTML =
|
||||
`<span class="provider-badge ${data.tenant.provider}">${providerLabel(data.tenant.provider)}</span> ${escapeHtml(data.tenant.name)}`;
|
||||
}
|
||||
|
||||
function renderStatusBanner(data) {
|
||||
const el = document.getElementById("status-banner");
|
||||
const status = data.status;
|
||||
const isMonthly = data.period.mode === "monthly";
|
||||
const hasAnyData = isMonthly ? data.has_monthly_data : data.has_data;
|
||||
|
||||
if (status && status.status === "error") {
|
||||
el.innerHTML = `
|
||||
<div class="banner error">
|
||||
<div>
|
||||
<strong>Dernière collecte en échec</strong> (${escapeHtml(status.period_start || "")}) :
|
||||
${escapeHtml(status.error_message || "erreur inconnue.")}
|
||||
</div>
|
||||
</div>`;
|
||||
} else if (!hasAnyData) {
|
||||
const msg = isMonthly
|
||||
? "Aucune donnée mensuelle pour ce tenant — lance <code>backfill_historical.py</code> ou <code>import_historical.py</code> pour peupler l'historique."
|
||||
: "Pas encore de données journalières pour ce tenant — la collecte quotidienne n'a pas encore tourné, ou seules des données historiques mensuelles ont été importées.";
|
||||
el.innerHTML = `
|
||||
<div class="banner error">
|
||||
<div><strong>Pas encore de données</strong> — ${msg}</div>
|
||||
</div>`;
|
||||
} else {
|
||||
el.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
function renderKpis(data) {
|
||||
const p = data.period;
|
||||
const isMonthly = p.mode === "monthly";
|
||||
const kpiRow = document.getElementById("kpi-row");
|
||||
kpiRow.classList.toggle("no-comparison", isMonthly);
|
||||
|
||||
if (isMonthly) {
|
||||
document.getElementById("kpi-current-label").textContent = `Total — ${p.months.length} derniers mois`;
|
||||
} else {
|
||||
document.getElementById("kpi-current-label").textContent = `Total — ${p.range_days} derniers jours`;
|
||||
}
|
||||
document.getElementById("kpi-current").textContent = formatEUR(p.current_total);
|
||||
|
||||
if (!isMonthly) {
|
||||
document.getElementById("kpi-previous-label").textContent = `Total — ${p.range_days} jours précédents`;
|
||||
document.getElementById("kpi-previous").textContent = p.has_previous ? formatEUR(p.previous_total) : "—";
|
||||
|
||||
const deltaEl = document.getElementById("kpi-delta");
|
||||
const delta = formatPct(p.delta_pct);
|
||||
deltaEl.textContent = delta || "—";
|
||||
deltaEl.className = `kpi-value kpi-delta ${deltaClass(p.delta_pct)}`;
|
||||
}
|
||||
|
||||
document.getElementById("kpi-ytd").textContent = formatEUR(data.ytd_total);
|
||||
document.getElementById("kpi-ytd-label").textContent = `Total depuis le 1er janvier ${new Date().getFullYear()}`;
|
||||
}
|
||||
|
||||
function dimmedColor(hex) {
|
||||
return Chart.helpers.color(hex).alpha(0.3).rgbString();
|
||||
}
|
||||
|
||||
function renderDailyChart(data) {
|
||||
const p = data.period;
|
||||
const isMonthly = p.mode === "monthly";
|
||||
currentMode = isMonthly ? "monthly" : "daily";
|
||||
currentBuckets = isMonthly ? p.months : p.days;
|
||||
const labelFn = isMonthly ? monthLabel : dayLabel;
|
||||
|
||||
document.getElementById("daily-chart-title").textContent = isMonthly
|
||||
? `Coûts mensuels — ${p.months.length} derniers mois`
|
||||
: `Coûts journaliers — ${p.range_days} derniers jours`;
|
||||
|
||||
if (dailyChart) {
|
||||
dailyChart.destroy();
|
||||
dailyChart = null;
|
||||
}
|
||||
|
||||
const wrap = document.querySelector("#daily-chart").parentElement;
|
||||
if (!currentBuckets.length) {
|
||||
wrap.innerHTML = `<div class="empty-state">Aucune donnée sur cette période.</div>`;
|
||||
return;
|
||||
}
|
||||
if (!wrap.querySelector("canvas")) {
|
||||
wrap.innerHTML = `<canvas id="daily-chart"></canvas>`;
|
||||
}
|
||||
|
||||
const ctx = document.getElementById("daily-chart").getContext("2d");
|
||||
const accent = cssVar("--series-1");
|
||||
const surface = cssVar("--surface-1");
|
||||
|
||||
dailyChart = new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: currentBuckets.map(labelFn),
|
||||
datasets: [
|
||||
{
|
||||
label: "Coût",
|
||||
data: p.amounts,
|
||||
backgroundColor: (ctx2) => {
|
||||
if (selectedBuckets.size === 0) return accent;
|
||||
return selectedBuckets.has(currentBuckets[ctx2.dataIndex]) ? accent : dimmedColor(accent);
|
||||
},
|
||||
borderColor: surface,
|
||||
borderWidth: { top: 2, right: 0, bottom: 0, left: 0 },
|
||||
borderSkipped: false,
|
||||
borderRadius: { topLeft: 4, topRight: 4 },
|
||||
maxBarThickness: isMonthly ? 32 : 18,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...chartBaseOptions(),
|
||||
onHover: (evt, elements) => {
|
||||
evt.native.target.style.cursor = elements.length ? "pointer" : "default";
|
||||
},
|
||||
onClick: (evt, elements) => {
|
||||
if (!elements.length) return;
|
||||
const bucket = currentBuckets[elements[0].index];
|
||||
if (selectedBuckets.has(bucket)) {
|
||||
selectedBuckets.delete(bucket);
|
||||
} else {
|
||||
selectedBuckets.add(bucket);
|
||||
}
|
||||
// Reporté au tick suivant : détruire ce chart depuis son propre
|
||||
// handler onClick corromprait les listeners internes de Chart.js.
|
||||
setTimeout(() => {
|
||||
renderDailyChart(data);
|
||||
refreshServicesSection();
|
||||
}, 0);
|
||||
},
|
||||
plugins: {
|
||||
...chartBaseOptions().plugins,
|
||||
tooltip: {
|
||||
...chartBaseOptions().plugins.tooltip,
|
||||
callbacks: {
|
||||
label: (item) => `Coût : ${formatEUR(item.parsed.y)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function bucketServices(services) {
|
||||
const entries = Object.entries(services);
|
||||
if (entries.length <= MAX_SERVICE_SLOTS) {
|
||||
return { top: entries, otherTotal: 0 };
|
||||
}
|
||||
const top = entries.slice(0, MAX_SERVICE_SLOTS - 1);
|
||||
const rest = entries.slice(MAX_SERVICE_SLOTS - 1);
|
||||
const otherTotal = rest.reduce((sum, [, amount]) => sum + amount, 0);
|
||||
return { top, otherTotal };
|
||||
}
|
||||
|
||||
function renderServicesChart(services) {
|
||||
const wrap = document.querySelector("#services-chart").parentElement;
|
||||
const entries = Object.entries(services);
|
||||
|
||||
if (entries.length === 0) {
|
||||
wrap.innerHTML = `<div class="empty-state">Aucun coût par service sur cette période.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, otherTotal } = bucketServices(services);
|
||||
const labels = top.map(([name]) => name).concat(otherTotal > 0 ? ["Autres"] : []);
|
||||
const values = top.map(([, amount]) => amount).concat(otherTotal > 0 ? [otherTotal] : []);
|
||||
const colors = top.map((_, i) => cssVar(SERIES_COLORS[i])).concat(otherTotal > 0 ? [cssVar("--series-other")] : []);
|
||||
|
||||
if (servicesChart) {
|
||||
servicesChart.destroy();
|
||||
servicesChart = null;
|
||||
}
|
||||
if (!wrap.querySelector("canvas")) {
|
||||
wrap.innerHTML = `<canvas id="services-chart"></canvas>`;
|
||||
}
|
||||
|
||||
const ctx = document.getElementById("services-chart").getContext("2d");
|
||||
servicesChart = new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data: values,
|
||||
backgroundColor: colors,
|
||||
maxBarThickness: 20,
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
indexAxis: "y",
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "nearest", intersect: true },
|
||||
onHover: (evt, elements) => {
|
||||
evt.native.target.style.cursor = elements.length && labels[elements[0].index] !== "Autres" ? "pointer" : "default";
|
||||
},
|
||||
onClick: (evt, elements) => {
|
||||
if (!elements.length) return;
|
||||
const label = labels[elements[0].index];
|
||||
if (label === "Autres") return;
|
||||
openResourceDrawer(label);
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: cssVar("--surface-1"),
|
||||
titleColor: cssVar("--text-primary"),
|
||||
bodyColor: cssVar("--text-secondary"),
|
||||
borderColor: cssVar("--border"),
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: { label: (item) => formatEUR(item.parsed.x) },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
grid: { color: cssVar("--gridline") },
|
||||
border: { display: false },
|
||||
ticks: { color: cssVar("--text-muted"), font: { size: 11 } },
|
||||
},
|
||||
y: {
|
||||
grid: { display: false },
|
||||
border: { color: cssVar("--axis") },
|
||||
ticks: { color: cssVar("--text-muted"), font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderServicesTable(services) {
|
||||
const wrap = document.getElementById("services-table-wrap");
|
||||
const entries = Object.entries(services);
|
||||
document.getElementById("services-chart-sub").textContent =
|
||||
entries.length > 0 ? `${entries.length} service${entries.length > 1 ? "s" : ""} identifié${entries.length > 1 ? "s" : ""}` : "";
|
||||
|
||||
if (entries.length === 0) {
|
||||
wrap.innerHTML = `<div class="empty-state">Rien à afficher.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const total = entries.reduce((sum, [, amount]) => sum + amount, 0) || 1;
|
||||
const rows = entries
|
||||
.map(([name, amount]) => {
|
||||
const pct = (amount / total) * 100;
|
||||
return `
|
||||
<tr class="clickable" data-service="${escapeHtml(name)}" title="Voir le détail par ressource">
|
||||
<td>${escapeHtml(name)}</td>
|
||||
<td class="num">${formatEUR(amount)}</td>
|
||||
<td class="num">${pct.toFixed(1)} %</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
wrap.innerHTML = `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Service</th><th class="num">Montant</th><th class="num">%</th></tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
|
||||
wrap.querySelectorAll("tr.clickable").forEach((tr) => {
|
||||
tr.addEventListener("click", () => openResourceDrawer(tr.dataset.service));
|
||||
});
|
||||
}
|
||||
|
||||
// --- Sélection de barres (jours/mois) -> filtre la répartition service --
|
||||
|
||||
function selectedBucketsInOrder() {
|
||||
return currentBuckets.filter((b) => selectedBuckets.has(b));
|
||||
}
|
||||
|
||||
function renderServicesSelectionNote() {
|
||||
const el = document.getElementById("services-selection-note");
|
||||
const titleEl = document.getElementById("services-section-title");
|
||||
|
||||
if (selectedBuckets.size === 0) {
|
||||
el.innerHTML = "";
|
||||
titleEl.textContent = "Répartition par service (30 derniers jours)";
|
||||
return;
|
||||
}
|
||||
|
||||
const labelFn = currentMode === "monthly" ? monthLabel : dayLabel;
|
||||
const ordered = selectedBucketsInOrder();
|
||||
const noun = currentMode === "monthly" ? "mois" : "jour";
|
||||
titleEl.textContent = `Répartition par service — ${ordered.length} ${noun}${ordered.length > 1 ? "s" : ""} sélectionné${ordered.length > 1 ? "s" : ""}`;
|
||||
|
||||
el.innerHTML = `
|
||||
<span>Sélection : ${ordered.map(labelFn).join(", ")}</span>
|
||||
<button type="button" id="clear-services-selection">Réinitialiser</button>`;
|
||||
document.getElementById("clear-services-selection").addEventListener("click", () => {
|
||||
selectedBuckets.clear();
|
||||
if (dailyChart) {
|
||||
dailyChart.update();
|
||||
}
|
||||
refreshServicesSection();
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshServicesSection() {
|
||||
renderServicesSelectionNote();
|
||||
|
||||
if (selectedBuckets.size === 0) {
|
||||
renderServicesChart(defaultServices);
|
||||
renderServicesTable(defaultServices);
|
||||
return;
|
||||
}
|
||||
|
||||
const ordered = selectedBucketsInOrder();
|
||||
const param = currentMode === "monthly"
|
||||
? `months=${ordered.map(encodeURIComponent).join(",")}`
|
||||
: `days=${ordered.map(encodeURIComponent).join(",")}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tenant/${PROVIDER}/${encodeURIComponent(NAME)}/services?${param}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
renderServicesChart(data.services);
|
||||
renderServicesTable(data.services);
|
||||
} catch (e) {
|
||||
document.getElementById("services-table-wrap").innerHTML =
|
||||
`<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drawer : détail par ressource d'un service ------------------------
|
||||
|
||||
function openResourceDrawer(serviceName) {
|
||||
document.getElementById("drawer-title").textContent = serviceName;
|
||||
document.getElementById("drawer-sub").textContent = "Chargement…";
|
||||
document.getElementById("drawer-body").innerHTML = `<div class="skeleton">Chargement…</div>`;
|
||||
document.getElementById("resource-drawer").classList.add("open");
|
||||
document.getElementById("resource-drawer").setAttribute("aria-hidden", "false");
|
||||
document.getElementById("drawer-backdrop").classList.add("open");
|
||||
|
||||
fetch(`/api/tenant/${PROVIDER}/${encodeURIComponent(NAME)}/resources/${encodeURIComponent(serviceName)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => renderDrawerContent(serviceName, data))
|
||||
.catch((e) => {
|
||||
document.getElementById("drawer-sub").textContent = "";
|
||||
document.getElementById("drawer-body").innerHTML =
|
||||
`<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDrawerContent(serviceName, data) {
|
||||
const subEl = document.getElementById("drawer-sub");
|
||||
const bodyEl = document.getElementById("drawer-body");
|
||||
|
||||
if (data.period_start && data.period_end) {
|
||||
subEl.textContent = `${dayLabel(data.period_start)} → ${dayLabel(data.period_end)}`;
|
||||
} else {
|
||||
subEl.textContent = "";
|
||||
}
|
||||
|
||||
if (data.status === "never_collected") {
|
||||
bodyEl.innerHTML = `<div class="empty-state">
|
||||
Le détail par ressource n'a pas encore été collecté pour ce tenant —
|
||||
il arrive au prochain passage de la collecte quotidienne.
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === "unavailable") {
|
||||
const hint = PROVIDER === "aws"
|
||||
? " Vérifie que «Resource IDs» est activé dans les préférences Cost Explorer de ce compte AWS."
|
||||
: "";
|
||||
bodyEl.innerHTML = `<div class="banner error">
|
||||
<div><strong>Détail par ressource indisponible</strong> pour ce service.${escapeHtml(hint)}<br>
|
||||
${escapeHtml(data.message || "")}</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === "error") {
|
||||
bodyEl.innerHTML = `<div class="banner error">
|
||||
<div><strong>Échec de la dernière collecte du détail ressources</strong> :
|
||||
${escapeHtml(data.message || "erreur inconnue")}</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.resources.length) {
|
||||
bodyEl.innerHTML = `<div class="empty-state">Aucune ressource identifiée pour ce service sur cette période.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const total = data.resources.reduce((sum, r) => sum + r.amount, 0) || 1;
|
||||
bodyEl.innerHTML = data.resources
|
||||
.map((r) => {
|
||||
const pct = (r.amount / total) * 100;
|
||||
return `
|
||||
<div class="resource-row" title="${escapeHtml(r.resource_id)}">
|
||||
<div>
|
||||
<div class="resource-name">${escapeHtml(r.resource_name)}</div>
|
||||
<div class="resource-pct">${pct.toFixed(1)} %</div>
|
||||
</div>
|
||||
<div class="resource-amount">${formatEUR(r.amount)}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function closeResourceDrawer() {
|
||||
document.getElementById("resource-drawer").classList.remove("open");
|
||||
document.getElementById("resource-drawer").setAttribute("aria-hidden", "true");
|
||||
document.getElementById("drawer-backdrop").classList.remove("open");
|
||||
}
|
||||
|
||||
function setupDrawer() {
|
||||
document.getElementById("drawer-close").addEventListener("click", closeResourceDrawer);
|
||||
document.getElementById("drawer-backdrop").addEventListener("click", closeResourceDrawer);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeResourceDrawer();
|
||||
});
|
||||
}
|
||||
|
||||
function setupRangeToggle() {
|
||||
const buttons = document.querySelectorAll("#range-toggle button");
|
||||
buttons.forEach((btn) => {
|
||||
btn.classList.toggle("active", Number(btn.dataset.range) === currentRange);
|
||||
btn.addEventListener("click", async () => {
|
||||
currentRange = Number(btn.dataset.range);
|
||||
buttons.forEach((b) => b.classList.toggle("active", b === btn));
|
||||
selectedBuckets.clear();
|
||||
const data = await loadDetail(currentRange);
|
||||
defaultServices = data.services;
|
||||
renderKpis(data);
|
||||
renderDailyChart(data);
|
||||
refreshServicesSection();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!PROVIDER || !NAME) {
|
||||
document.getElementById("status-banner").innerHTML =
|
||||
`<div class="banner error"><strong>URL invalide.</strong></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await loadDetail(currentRange);
|
||||
defaultServices = data.services;
|
||||
renderHeader(data);
|
||||
renderStatusBanner(data);
|
||||
renderKpis(data);
|
||||
renderDailyChart(data);
|
||||
refreshServicesSection();
|
||||
setupRangeToggle();
|
||||
setupDrawer();
|
||||
} catch (e) {
|
||||
document.getElementById("status-banner").innerHTML =
|
||||
`<div class="banner error"><strong>Erreur de chargement</strong> : ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -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"> </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"> </p>
|
||||
</div>
|
||||
<button class="drawer-close" id="drawer-close" aria-label="Fermer">×</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>
|
||||
@@ -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
|
||||
@@ -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 »).
|
||||
Reference in New Issue
Block a user