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.
This commit is contained in:
ludal committed 2026-09-20 21:55:52 +02:00
1 parent 9e78dfa4f2
commit c39364eda4
12 files changed
+834 -114

No files matched your search

+233
View File
@@ -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)
# ----------------------------------------------------------------------