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
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 ludal
|
Copyright (c) 2026 ludovic.cartier@brainsys.io
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
|
|||||||
@@ -1,2 +1,290 @@
|
|||||||
# simple-cost-dashboard
|
# simple-cost-dashboard
|
||||||
|
|
||||||
|
Suivi centralisé des coûts AWS et Azure pour une multitude de tenants,
|
||||||
|
avec conversion automatique en EUR et historique conservé en base SQLite.
|
||||||
|
Dashboard web (page globale + détail par tenant) alimenté par une collecte
|
||||||
|
quotidienne (cron).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Installation locale (sans Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd simple-cost-dashboard
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Configuration des tenants
|
||||||
|
|
||||||
|
Crée un fichier `config.json` à la racine du projet (à côté de ce README) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aws": [
|
||||||
|
{ "name": "analytics-platform", "profile": "analytics-platform" },
|
||||||
|
{
|
||||||
|
"name": "client-a-prod",
|
||||||
|
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
|
||||||
|
"secret_access_key": "VOTRE_SECRET_ICI"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"azure": [
|
||||||
|
{
|
||||||
|
"name": "client-c-prod",
|
||||||
|
"tenant_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"client_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"client_secret": "VOTRE_SECRET_ICI",
|
||||||
|
"subscription_id": "22222222-2222-2222-2222-222222222222"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **AWS** — deux modes au choix par tenant, dans l'ordre de priorité
|
||||||
|
`access_key_id`/`secret_access_key` puis `profile` :
|
||||||
|
- `access_key_id` + `secret_access_key` : clé IAM dédiée au tenant,
|
||||||
|
limitée au droit `ce:GetCostAndUsage`. **Seul mode qui fonctionne en
|
||||||
|
conteneur/Kubernetes** — il ne dépend d'aucun fichier ni état sur
|
||||||
|
l'hôte, contrairement à `profile`.
|
||||||
|
- `profile` : un profil déjà configuré dans `~/.aws/credentials` ou
|
||||||
|
`~/.aws/config`, pratique en local. Nécessite de monter `~/.aws` dans
|
||||||
|
le conteneur (`-v ~/.aws:/home/appuser/.aws:ro`) — et **ne fonctionne
|
||||||
|
pas avec un profil SSO** en conteneur : le token mis en cache finit
|
||||||
|
par expirer, et son renouvellement demande un navigateur, indisponible
|
||||||
|
côté conteneur/CronJob. Réservé au dev local avec des clés statiques
|
||||||
|
ou un profil SSO valide au moment du run.
|
||||||
|
- **Azure** : un service principal par tenant (`az ad sp create-for-rbac`),
|
||||||
|
avec le rôle **Cost Management Reader** sur la subscription visée.
|
||||||
|
- Le champ `"name"` est l'identifiant utilisé partout (URLs, dashboard).
|
||||||
|
Deux tenants peuvent avoir le même `name` sur des CSP différents (ex:
|
||||||
|
`analytics-platform` en AWS **et** en Azure) — ils restent distingués
|
||||||
|
par le couple `(provider, name)`, y compris dans les URLs
|
||||||
|
(`/tenant/aws/analytics-platform` vs `/tenant/azure/analytics-platform`).
|
||||||
|
|
||||||
|
⚠️ `config.json` contient des secrets (`client_secret` Azure, `secret_access_key`
|
||||||
|
AWS). Ne le commite pas — il est déjà exclu via `.gitignore`.
|
||||||
|
|
||||||
|
## 3. Base de données
|
||||||
|
|
||||||
|
La base SQLite (`cost_dashboard.db` par défaut, chemin surchargeable via
|
||||||
|
`COST_DASHBOARD_DB`) et son schéma sont créés automatiquement au premier
|
||||||
|
lancement de `collect.py`, `import_historical.py` ou de l'API. Rien à
|
||||||
|
faire manuellement. Le fichier `db/schema.sql` décrit la structure.
|
||||||
|
|
||||||
|
## 4. Importer l'historique (une seule fois)
|
||||||
|
|
||||||
|
**Depuis AWS/Azure directement**, pour peupler la base avec tout l'historique
|
||||||
|
disponible sans étape intermédiaire :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd collector
|
||||||
|
python backfill_historical.py --config ../config.json --db ../cost_dashboard.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Rapatrie 12 mois par défaut (`--months` pour ajuster), un par un, pour
|
||||||
|
chaque tenant de `config.json`. Limites à connaître :
|
||||||
|
- **AWS Cost Explorer ne conserve que 12 mois glissants** — au-delà, l'API
|
||||||
|
ne renvoie rien (pas une erreur), donc `--months` plus grand n'aide pas
|
||||||
|
côté AWS. Azure dépend du contrat (souvent plus long).
|
||||||
|
- Chaque mois interrogé est un appel Cost Explorer/Cost Management facturé
|
||||||
|
(~0,01 $ la requête AWS) — pour 28 tenants sur 12 mois, prévoir ~336
|
||||||
|
requêtes (quelques dollars, payés une fois).
|
||||||
|
- `--tenant <nom>` pour ne rapatrier qu'un seul tenant (ex: nouvellement
|
||||||
|
ajouté à `config.json`).
|
||||||
|
|
||||||
|
En plus de l'historique mensuel, ce même script rapatrie aussi les
|
||||||
|
**30 derniers jours en granularité journalière** (`--daily-days`, 0 pour
|
||||||
|
désactiver) — sinon les graphes 7j/30j de la page tenant restent vides
|
||||||
|
jusqu'à ce que le cron ait tourné 7 à 30 jours de suite pour les remplir.
|
||||||
|
Rejoue exactement `collect_tenant`/`collect_tenant_resources` de
|
||||||
|
`collect.py` (mêmes fonctions que le cron), jour par jour : même ordre de
|
||||||
|
grandeur en appels facturés que le backfill mensuel (~840 requêtes pour
|
||||||
|
28 tenants sur 30 jours), et ça prend plusieurs minutes (appels
|
||||||
|
séquentiels). Le cron quotidien écrase ensuite ces jours au fil de l'eau
|
||||||
|
sans créer de doublons (upsert sur la même clé).
|
||||||
|
|
||||||
|
⚠️ **Côté Azure**, l'API Cost Management est plus vite sujette au
|
||||||
|
rate-limiting (429) que Cost Explorer côté AWS — surtout en rafale sur les
|
||||||
|
dizaines d'appels d'un backfill. `providers.py` retente automatiquement les
|
||||||
|
429 (jusqu'à 8 fois), en respectant le délai suggéré par Azure — pas le
|
||||||
|
`Retry-After` HTTP standard, mais ses propres en-têtes
|
||||||
|
`x-ms-ratelimit-microsoft.costmanagement-{entity,tenant,clienttype}-retry-after`,
|
||||||
|
le plus restrictif faisant foi quand plusieurs sont présents. `backfill_historical.py`
|
||||||
|
ajoute en plus une pause de 2s entre deux appels Azure pour limiter le
|
||||||
|
risque d'en déclencher. Si un tenant Azure échoue quand même après ça,
|
||||||
|
relancer le backfill juste sur lui (`--tenant <nom>`) suffit généralement.
|
||||||
|
|
||||||
|
**Depuis des exports JSON existants** (si tu as déjà des données de coûts
|
||||||
|
passées, exportées via `cost_report.py --json` ou `tenant_detail.py --json`,
|
||||||
|
par exemple générées avant la mise en place de ce dashboard) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python import_historical.py --db ../cost_dashboard.db ../historique/*.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Les deux stockent avec `granularity='monthly'`, pour ne pas se mélanger avec
|
||||||
|
la collecte journalière (qui prime dessus dans les agrégations dès qu'elle a
|
||||||
|
des données pour un mois donné). Les deux sont ré-exécutables sans créer de
|
||||||
|
doublons.
|
||||||
|
|
||||||
|
## 5. Collecte quotidienne (cron)
|
||||||
|
|
||||||
|
`collect.py` récupère le coût de **J-1** (les données du jour même sont
|
||||||
|
rarement finalisées côté AWS/Azure) pour chaque tenant, le convertit en
|
||||||
|
EUR (taux figé au moment de la collecte), et l'écrit en base avec
|
||||||
|
`granularity='daily'`.
|
||||||
|
|
||||||
|
**Test manuel :**
|
||||||
|
```bash
|
||||||
|
cd collector
|
||||||
|
python collect.py --config ../config.json --db ../cost_dashboard.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rattraper une journée précise :**
|
||||||
|
```bash
|
||||||
|
python collect.py --config ../config.json --db ../cost_dashboard.db --date 2026-07-28
|
||||||
|
```
|
||||||
|
|
||||||
|
Un échec sur un tenant (token expiré, permissions...) n'empêche pas la
|
||||||
|
collecte des autres — journalisé en base (`status='error'`) et affiché
|
||||||
|
dans le dashboard plutôt que de laisser un trou silencieux.
|
||||||
|
|
||||||
|
Juste après, pour chaque tenant collecté avec succès, `collect.py` récupère
|
||||||
|
aussi le **détail par ressource** (bucket S3, instance EC2, VM Azure...) sur
|
||||||
|
une fenêtre glissante de **14 jours** — c'est le maximum que fournit AWS Cost
|
||||||
|
Explorer pour ce niveau de détail (limite dure de l'API, pas de notre code),
|
||||||
|
gardé identique côté Azure par cohérence. Alimente le panneau qui s'ouvre au
|
||||||
|
clic sur un service dans le dashboard (§6). Côté AWS, ça suppose d'avoir
|
||||||
|
activé **"Resource IDs"** dans les préférences Cost Explorer du compte — si
|
||||||
|
ce n'est pas fait, la collecte du détail échoue proprement pour ce tenant
|
||||||
|
(`resource_collection_status.status = 'unavailable'`) sans bloquer le reste,
|
||||||
|
et le dashboard l'affiche avec un message explicite plutôt qu'un vide.
|
||||||
|
|
||||||
|
**En dehors de Docker/Kubernetes**, installation en crontab classique :
|
||||||
|
```
|
||||||
|
0 6 * * * cd /opt/simple-cost-dashboard/collector && /opt/simple-cost-dashboard/venv/bin/python collect.py --config ../config.json --db ../cost_dashboard.db >> ../cron.log 2>&1
|
||||||
|
```
|
||||||
|
(Voir §7 pour l'équivalent en CronJob Kubernetes.)
|
||||||
|
|
||||||
|
## 6. Lancer le dashboard web
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api
|
||||||
|
uvicorn main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis ouvre `http://localhost:8000`.
|
||||||
|
|
||||||
|
- `/` — vue globale : tous les tenants, coûts mensuels sur 12 mois glissants
|
||||||
|
- `/tenant/<aws|azure>/<nom>` — détail d'un tenant : graph 7j/30j avec
|
||||||
|
comparaison à la période précédente, répartition par service (30
|
||||||
|
derniers jours), total depuis le 1er janvier. Cliquer sur un service (dans
|
||||||
|
le graphe ou le tableau) ouvre un panneau avec le détail par ressource
|
||||||
|
(bucket, instance, VM...) sur les 14 derniers jours — voir §5 pour les
|
||||||
|
limites côté AWS (fenêtre 14j, "Resource IDs" à activer).
|
||||||
|
- `/health` — endpoint de liveness/readiness (utilisé par Kubernetes)
|
||||||
|
|
||||||
|
## 7. Docker
|
||||||
|
|
||||||
|
**Build :**
|
||||||
|
```bash
|
||||||
|
docker build -t simple-cost-dashboard:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lancer le dashboard web :**
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name cost-dashboard \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-v $(pwd)/data:/data \
|
||||||
|
-v $(pwd)/config.json:/app/config.json:ro \
|
||||||
|
simple-cost-dashboard:latest
|
||||||
|
```
|
||||||
|
La base SQLite vit dans `/data` (volume monté) pour survivre aux
|
||||||
|
redémarrages/recréations du container.
|
||||||
|
|
||||||
|
**Lancer une collecte ponctuelle (même image, commande différente) :**
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v $(pwd)/data:/data \
|
||||||
|
-v $(pwd)/config.json:/app/config.json:ro \
|
||||||
|
simple-cost-dashboard:latest \
|
||||||
|
python collector/collect.py --config /app/config.json --db /data/cost_dashboard.db
|
||||||
|
```
|
||||||
|
|
||||||
|
C'est la **même image** pour le serveur web et pour la collecte — seule la
|
||||||
|
commande change. C'est le pattern que je reprendrai pour Kubernetes :
|
||||||
|
un `Deployment` (serveur web, commande par défaut de l'image) et un
|
||||||
|
`CronJob` (collecte, commande surchargée), tous deux basés sur la même
|
||||||
|
image `simple-cost-dashboard:latest`.
|
||||||
|
|
||||||
|
## 8. Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
simple-cost-dashboard/
|
||||||
|
├── Dockerfile
|
||||||
|
├── .dockerignore
|
||||||
|
├── requirements.txt
|
||||||
|
├── config.json # à créer (voir §2) — non versionné
|
||||||
|
├── cost_dashboard.db # créé automatiquement (local) / dans /data (Docker)
|
||||||
|
├── db/
|
||||||
|
│ └── schema.sql # schéma de la base
|
||||||
|
├── collector/
|
||||||
|
│ ├── providers.py # accès AWS Cost Explorer / Azure Cost Mgmt + conversion EUR
|
||||||
|
│ ├── db.py # connexion SQLite + écriture des relevés
|
||||||
|
│ ├── collect.py # cron quotidien (granularité journalière + détail ressources 14j)
|
||||||
|
│ ├── backfill_historical.py # rapatrie l'historique dispo depuis AWS/Azure (voir §4)
|
||||||
|
│ ├── import_historical.py # import ponctuel d'exports JSON déjà générés
|
||||||
|
│ ├── cost_report.py # CLI ponctuel : rapport multi-tenants
|
||||||
|
│ └── tenant_detail.py # CLI ponctuel : détail d'un tenant
|
||||||
|
├── api/
|
||||||
|
│ ├── main.py # app FastAPI (routes API + pages statiques + /health)
|
||||||
|
│ └── queries.py # agrégations (vue mensuelle, YTD, comparaison, services, ressources)
|
||||||
|
├── frontend/
|
||||||
|
│ ├── index.html # page globale
|
||||||
|
│ ├── tenant.html # page détail tenant
|
||||||
|
│ └── static/
|
||||||
|
│ ├── style.css
|
||||||
|
│ ├── common.js # utilitaires partagés (formatage, config Chart.js)
|
||||||
|
│ ├── app.js # logique page globale
|
||||||
|
│ └── tenant.js # logique page détail + graphes + drawer ressources
|
||||||
|
└── deploy/ # à venir : manifestes Kubernetes (voir TODO)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. À faire
|
||||||
|
|
||||||
|
**Déploiement Kubernetes** (`deploy/`, pas encore créé) :
|
||||||
|
- [ ] Manifeste `Deployment` pour le serveur web (readiness/liveness sur `/health`)
|
||||||
|
- [ ] Manifeste `Service` + `Ingress`
|
||||||
|
- [ ] Manifeste `CronJob` pour la collecte quotidienne, même image, commande surchargée
|
||||||
|
- [ ] `PersistentVolumeClaim` pour `/data` (la base SQLite doit survivre aux redéploiements)
|
||||||
|
- [ ] `Secret` Kubernetes pour `config.json` (credentials Azure notamment) plutôt qu'un ConfigMap en clair
|
||||||
|
|
||||||
|
**Authentification** :
|
||||||
|
- [ ] Basic Auth via l'Ingress (annotation nginx `auth-basic` + `Secret` `htpasswd`) — solution de départ validée
|
||||||
|
- [ ] Bascule vers SSO Entra ID (app d'entreprise Azure + groupe) une fois le besoin confirmé — probablement via `oauth2-proxy` en sidecar/Ingress plutôt que dans le code de l'appli, pour ne pas coupler l'auth à FastAPI
|
||||||
|
|
||||||
|
**Credentials AWS/Azure en environnement conteneurisé** :
|
||||||
|
- [x] AWS : tranché pour des clés IAM dédiées par tenant (`access_key_id`/
|
||||||
|
`secret_access_key` dans `config.json`, droit `ce:GetCostAndUsage`
|
||||||
|
uniquement) plutôt que des profils `~/.aws` — les profils SSO ne
|
||||||
|
fonctionnent pas en conteneur (le refresh du token demande un
|
||||||
|
navigateur). Voir §2. Pas encore fait : créer ces clés IAM dédiées côté
|
||||||
|
AWS pour chaque tenant (elles n'existent pas encore, seuls des profils
|
||||||
|
SSO existent aujourd'hui) — et envisager `sts:AssumeRole` depuis une
|
||||||
|
identité unique si la rotation de 28+ clés IAM statiques devient trop
|
||||||
|
lourde à gérer.
|
||||||
|
- [ ] `config.json` (les clés AWS et les `client_secret` Azure) doit passer
|
||||||
|
en `Secret` Kubernetes monté en volume une fois `deploy/` créé, pas en
|
||||||
|
ConfigMap.
|
||||||
|
|
||||||
|
**Fiabilité / observabilité** :
|
||||||
|
- [ ] Alerte (Slack/email) si un tenant est en échec de collecte plusieurs jours de suite — actuellement visible seulement en se rendant sur le dashboard
|
||||||
|
- [ ] Sauvegarde régulière de la base SQLite (le PVC seul ne protège pas d'une corruption)
|
||||||
|
- [ ] Limite de rétention ou archivage si le volume de la base grossit significativement à long terme (non urgent : le volume attendu reste faible)
|
||||||
|
|
||||||
|
**Fonctionnel** :
|
||||||
|
- [ ] Vérifier le rendu du dashboard avec de vraies données (actuellement testé uniquement sur la structure du code, pas sur un run réel bout-en-bout)
|
||||||
|
- [ ] Décider si la page globale doit aussi permettre de filtrer/rechercher un tenant si leur nombre grossit beaucoup
|
||||||
+115
@@ -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