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:
1 parent
9e78dfa4f2
commit
c39364eda4
12 files changed
+834
-114
No files matched your search
+39
-2
@@ -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)
|
||||
|
||||
+233
@@ -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)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user