#!/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("/services", response_class=HTMLResponse) def services_page(): return _read_page("services.html") @app.get("/collecte", response_class=HTMLResponse) def collecte_page(): return _read_page("collecte.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() 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 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) 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() @app.get("/api/collection-runs") def api_collection_runs(limit: int = Query(50, ge=1, le=200)): conn = queries.get_connection() try: return {"runs": queries.list_collection_runs(conn, limit=limit)} finally: conn.close() @app.get("/api/collection-runs/alert-status") def api_collection_runs_alert_status(): conn = queries.get_connection() try: return {"has_unacknowledged_errors": queries.has_unacknowledged_errors(conn)} finally: conn.close() @app.get("/api/collection-runs/{run_id}/errors") def api_collection_run_errors(run_id: int): conn = queries.get_connection() try: return {"errors": queries.collection_run_errors(conn, run_id)} finally: conn.close() @app.post("/api/collection-runs/{run_id}/acknowledge") def api_acknowledge_collection_run(run_id: int): conn = queries.get_connection() try: acknowledged = queries.acknowledge_collection_run(conn, run_id) if not acknowledged: raise HTTPException(status_code=404, detail="Exécution introuvable ou déjà acquittée") return {"status": "acknowledged"} finally: conn.close()