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