first (big) draft
This commit is contained in:
+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,
|
||||
}
|
||||
Reference in New Issue
Block a user