Files
simple-cost-dashboard/api/queries.py
T
ludal c39364eda4 feat: add services page and API endpoints for service cost breakdown
- Implemented new API endpoints for fetching global services and service tenants details.
- Created a new services.html page with a sidebar for navigation and a chart for service costs.
- Updated frontend JavaScript to handle service data loading and rendering.
- Enhanced the dashboard with provider filtering options for AWS and Azure.
- Added a segmented toggle for selecting the date range for service costs.
- Improved styling for the sidebar and data tables.
- Refactored common utility functions for better reusability across pages.
2026-09-20 21:55:52 +02:00

659 lines
25 KiB
Python

#!/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]))
# ----------------------------------------------------------------------
# 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)
# ----------------------------------------------------------------------
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,
}